mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-09 04:36:02 -05:00
Widget user profile for all (#3384)
This commit is contained in:
@@ -1,37 +0,0 @@
|
||||
export function BattlefyIcon() {
|
||||
return (
|
||||
<svg
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 1152 1152"
|
||||
enableBackground="new 0 0 1152 1152"
|
||||
xmlSpace="preserve"
|
||||
>
|
||||
<path
|
||||
display="none"
|
||||
fill="#151B27"
|
||||
d="M1152,1099.3c0,29.4-23.8,52.7-53.2,52.7H52.8c-29.4,0-52.8-23.4-52.8-52.7V53.2
|
||||
C0,23.8,23.4,0,52.8,0h1046.1c29.4,0,53.2,23.8,53.2,53.2V1099.3z"
|
||||
/>
|
||||
<g>
|
||||
<path
|
||||
fill="#DD4B5E"
|
||||
d="M222.5,399.8c1.5-18.1,79.5-154.8,99.7-166.1c20.2-11.3,211.5-22.9,211.5-22.9S368.9,346.1,331.5,555.9
|
||||
c-37.3,209.8-1.3,374.1-1.3,374.1S218.8,444.5,222.5,399.8z"
|
||||
/>
|
||||
<path
|
||||
fill="#DD4B5E"
|
||||
d="M467.6,753.3c0,0,242.8-431.4,522.1-542.6l-154,520L342.4,941.2c0,0,417.4-276.9,449.6-289.8l93.3-307.7
|
||||
C885.4,343.8,548.5,641.2,467.6,753.3z"
|
||||
/>
|
||||
<path
|
||||
fill="#DD4B5E"
|
||||
d="M672.9,400.4c0,0-203.8,193.6-257.9,351.2c0,0-26-108.8-19.5-133.2L672.9,400.4z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -33,7 +33,6 @@ export const JSON_COLUMNS: ReadonlySet<string> = new Set([
|
||||
"TournamentTeam.activeRosterUserIds",
|
||||
"User.buildSorting",
|
||||
"User.customTheme",
|
||||
"User.favoriteBadgeIds",
|
||||
"User.favoriteTrophyIds",
|
||||
"User.hiddenCardStats",
|
||||
"User.hiddenTrophyIds",
|
||||
|
||||
@@ -19,9 +19,9 @@ describe("computedJsonColumns", () => {
|
||||
...commonUserSelect(eb, { inTournament: true }),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("UserWeapon")
|
||||
.select("UserWeapon.weaponSplId")
|
||||
.whereRef("UserWeapon.userId", "=", "User.id"),
|
||||
.selectFrom("UserWeaponPool")
|
||||
.select("UserWeaponPool.weaponSplId")
|
||||
.whereRef("UserWeaponPool.userId", "=", "User.id"),
|
||||
).as("weapons"),
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { BADGE } from "~/features/badges/badges-constants";
|
||||
import { DEFAULT_WIDGETS } from "~/features/user-page/core/widgets/portfolio";
|
||||
import type { StoredWidget } from "~/features/user-page/core/widgets/types";
|
||||
import { faker } from "../core/faker";
|
||||
import badges from "../data/badges.json";
|
||||
import * as BadgeFactory from "../factories/BadgeFactory";
|
||||
import * as UserFactory from "../factories/UserFactory";
|
||||
import type { SeededUsers } from "./users";
|
||||
import { nzapWidgets, type SeededUsers } from "./users";
|
||||
|
||||
const HOMEMADE_BADGE_COUNT = 5;
|
||||
const NZAP_BADGE_COUNT = 20;
|
||||
@@ -95,13 +97,27 @@ function fakeOwnerIds({
|
||||
|
||||
async function seedFavoriteBadges(users: SeededUsers, badgeIds: number[]) {
|
||||
for (const [i, userId] of users.favoriteBadgeUserIds.entries()) {
|
||||
await UserFactory.updateProfile(userId, {
|
||||
favoriteBadgeIds: [badgeIds[i % 3]],
|
||||
await UserFactory.grant(userId, {
|
||||
widgets: withFavoriteBadges(DEFAULT_WIDGETS, [badgeIds[i % 3]]),
|
||||
});
|
||||
}
|
||||
|
||||
// a supporter picks a whole row of small badges alongside the big one
|
||||
await UserFactory.updateProfile(users.nzapId, {
|
||||
favoriteBadgeIds: badgeIds.slice(0, NZAP_FAVORITE_BADGE_COUNT),
|
||||
await UserFactory.grant(users.nzapId, {
|
||||
widgets: withFavoriteBadges(
|
||||
nzapWidgets(),
|
||||
badgeIds.slice(0, NZAP_FAVORITE_BADGE_COUNT),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function withFavoriteBadges(
|
||||
widgets: StoredWidget[],
|
||||
favoriteBadgeIds: number[],
|
||||
): StoredWidget[] {
|
||||
return widgets.map((widget) =>
|
||||
widget.id === "badges-owned"
|
||||
? { ...widget, settings: { favoriteBadgeIds } }
|
||||
: widget,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { BANNED_MAPS } from "~/features/match-profile/banned-maps";
|
||||
import { AMOUNT_OF_MAPS_IN_POOL_PER_MODE } from "~/features/match-profile/match-profile-constants";
|
||||
import { LUTI_DIVS } from "~/features/scrims/scrims-constants";
|
||||
import { PRESET_COLORS } from "~/features/tier-list-maker/tier-list-maker-constants";
|
||||
import { DEFAULT_WIDGETS } from "~/features/user-page/core/widgets/portfolio";
|
||||
import type { StoredWidget } from "~/features/user-page/core/widgets/types";
|
||||
import type { UnifiedLanguageCode } from "~/modules/i18n/config";
|
||||
import { modesShort } from "~/modules/in-game-lists/modes";
|
||||
import { stageIds } from "~/modules/in-game-lists/stage-ids";
|
||||
@@ -36,7 +38,7 @@ export type SeededUsers = {
|
||||
};
|
||||
|
||||
export async function seedUsers(): Promise<SeededUsers> {
|
||||
// the plainest of the two profiles: no supporter perks, so the old profile page
|
||||
// the plainest of the two profiles: no widgets of their own, so the default layout
|
||||
const admin = await UserFactory.createAdmin(
|
||||
{
|
||||
discordId: ADMIN_DISCORD_ID,
|
||||
@@ -48,14 +50,16 @@ export async function seedUsers(): Promise<SeededUsers> {
|
||||
country: "FI",
|
||||
customUrl: "sendou",
|
||||
inGameName: "Sendou#1234",
|
||||
bio: showcaseNames.postText(),
|
||||
weapons: [{ weaponSplId: 200, isFavorite: 0 }],
|
||||
},
|
||||
friendCode: "0109-8080-3707",
|
||||
},
|
||||
{
|
||||
roles: ["VIDEO_ADDER", "TOURNAMENT_ORGANIZER", "ARTIST"],
|
||||
matchProfile: { mapModePreferences: fakePreferences(), vc: "YES" },
|
||||
matchProfile: {
|
||||
mapModePreferences: fakePreferences(),
|
||||
vc: "YES",
|
||||
weaponPool: [{ id: 200, isFavorite: false }],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -71,15 +75,8 @@ export async function seedUsers(): Promise<SeededUsers> {
|
||||
profile: {
|
||||
country: "SE",
|
||||
customUrl: "nzap",
|
||||
motionSens: 50,
|
||||
stickSens: 5,
|
||||
pronouns: JSON.stringify({ subject: "they", object: "them" }),
|
||||
inGameName: "N-ZAP#5678",
|
||||
bio: showcaseNames.maxLengthBio(),
|
||||
weapons: ([200, 1100, 2000, 4000] as const).map((weaponSplId) => ({
|
||||
weaponSplId,
|
||||
isFavorite: 0 as const,
|
||||
})),
|
||||
},
|
||||
friendCode: "1234-5678-9012",
|
||||
},
|
||||
@@ -96,7 +93,6 @@ export async function seedUsers(): Promise<SeededUsers> {
|
||||
})),
|
||||
},
|
||||
card: { shortBio: "Supporter of sendou.ink" },
|
||||
preferences: { newProfileEnabled: true },
|
||||
widgets: nzapWidgets(),
|
||||
},
|
||||
);
|
||||
@@ -141,7 +137,7 @@ async function seedShowcaseUsers() {
|
||||
const artistIds: number[] = [];
|
||||
const favoriteBadgeUserIds: number[] = [];
|
||||
|
||||
for (const [i, customName] of showcaseNames.CUSTOM_NAMES.entries()) {
|
||||
for (const customName of showcaseNames.CUSTOM_NAMES) {
|
||||
const hasKanji = /[一-龯]/u.test(customName);
|
||||
|
||||
const user = await UserFactory.create(
|
||||
@@ -151,8 +147,6 @@ async function seedShowcaseUsers() {
|
||||
inGameName: hasKanji
|
||||
? showcaseNames.kanaInGameName()
|
||||
: SplatoonFaker.inGameName(),
|
||||
bio: i === 0 ? showcaseNames.maxLengthBio() : undefined,
|
||||
weapons: [],
|
||||
},
|
||||
},
|
||||
showcaseOptions(),
|
||||
@@ -174,20 +168,14 @@ async function seedShowcaseUsers() {
|
||||
customName: showcaseNames.customName(),
|
||||
customUrl: "maximal",
|
||||
country: "JP",
|
||||
bio: showcaseNames.maxLengthBio(),
|
||||
pronouns: JSON.stringify({ subject: "they", object: "them" }),
|
||||
motionSens: -25,
|
||||
stickSens: 10,
|
||||
inGameName: showcaseNames.kanaInGameName(),
|
||||
weapons: SplatoonFaker.mainWeapons(5).map((weaponSplId) => ({
|
||||
weaponSplId,
|
||||
isFavorite: 1,
|
||||
})),
|
||||
},
|
||||
},
|
||||
{
|
||||
...showcaseOptions(),
|
||||
patronTier: 2,
|
||||
widgets: migratedWidgets(),
|
||||
card: {
|
||||
shortBio: faker.lorem.sentence(),
|
||||
bannerPresetImg: String(faker.helpers.arrayElement(stageIds)),
|
||||
@@ -211,20 +199,12 @@ async function seedShowcaseUsers() {
|
||||
customUrl: faker.number.float(1) < 0.2 ? `showcase-${i}` : undefined,
|
||||
country:
|
||||
faker.number.float(1) < 0.8 ? UserFactory.fakeCountry() : undefined,
|
||||
bio:
|
||||
faker.number.float(1) < 0.5 ? showcaseNames.postText() : undefined,
|
||||
inGameName:
|
||||
faker.number.float(1) < 0.4
|
||||
? showcaseNames.kanaInGameName()
|
||||
: SplatoonFaker.inGameName(),
|
||||
commissionsOpen: commissionsOpen ? 1 : undefined,
|
||||
commissionText: commissionsOpen ? faker.lorem.paragraph() : undefined,
|
||||
weapons: SplatoonFaker.mainWeapons(
|
||||
faker.helpers.arrayElement([1, 2, 3, 4]),
|
||||
).map((weaponSplId) => ({
|
||||
weaponSplId,
|
||||
isFavorite: faker.number.float(1) < 0.2 ? 1 : 0,
|
||||
})),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -242,10 +222,27 @@ async function seedShowcaseUsers() {
|
||||
return { ids, artistIds, favoriteBadgeUserIds };
|
||||
}
|
||||
|
||||
/** The one seeded supporter's widgets: both slots filled, every widget other modules seed content for. */
|
||||
function nzapWidgets(): NonNullable<
|
||||
/** What the widget backfill migration leaves a user who had a bio and sensitivity saved. */
|
||||
function migratedWidgets(): NonNullable<
|
||||
Parameters<typeof UserFactory.create>[1]
|
||||
>["widgets"] {
|
||||
return DEFAULT_WIDGETS.map((widget) => {
|
||||
if (widget.id === "bio") {
|
||||
return { ...widget, settings: { bio: showcaseNames.maxLengthBio() } };
|
||||
}
|
||||
if (widget.id === "sens") {
|
||||
return {
|
||||
...widget,
|
||||
settings: { ...widget.settings, motionSens: -25, stickSens: 10 },
|
||||
};
|
||||
}
|
||||
|
||||
return widget;
|
||||
});
|
||||
}
|
||||
|
||||
/** The one seeded supporter's widgets: both slots filled to the supporter limits. */
|
||||
export function nzapWidgets(): StoredWidget[] {
|
||||
return [
|
||||
{ id: "bio-md", settings: { bio: showcaseNames.maxLengthBio() } },
|
||||
{ id: "teams" },
|
||||
@@ -258,15 +255,10 @@ function nzapWidgets(): NonNullable<
|
||||
{ id: "timezone", settings: { timezone: "Europe/Stockholm" } },
|
||||
{ id: "social-links" },
|
||||
{ id: "weapon-pool" },
|
||||
{ id: "badges-owned" },
|
||||
{ id: "badges-owned", settings: { favoriteBadgeIds: [] } },
|
||||
{ id: "trophies-owned" },
|
||||
{ id: "builds" },
|
||||
{ id: "videos" },
|
||||
{ id: "art", settings: { source: "ALL" } },
|
||||
{ id: "x-rank-peaks", settings: { division: "both" } },
|
||||
{ id: "peak-sp" },
|
||||
{ id: "peak-xp" },
|
||||
{ id: "friends" },
|
||||
{ id: "highlighted-results" },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import * as MatchProfileRepository from "~/features/match-profile/MatchProfileRe
|
||||
import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { toDBBoolean } from "~/utils/sql";
|
||||
import {
|
||||
ORG_ADMIN_TEST_ID,
|
||||
REGULAR_USER_TEST_ID,
|
||||
@@ -46,11 +45,9 @@ type Options = {
|
||||
ban?: Omit<Parameters<typeof AdminRepository.banUser>[0], "userId">;
|
||||
/** Division the user played their last season in. */
|
||||
div?: NonNullable<Tables["User"]["div"]>;
|
||||
/** Weapon pool, submitted as the user themselves. */
|
||||
weapons?: Parameters<typeof UserRepository.updateOwnProfile>[0]["weapons"];
|
||||
/** User card fields, submitted as the user themselves. */
|
||||
card?: Partial<CardArgs>;
|
||||
/** Replaces the user's widgets. Only shown to a supporter with `newProfileEnabled` in `preferences`. */
|
||||
/** Replaces the user's widgets, i.e. their profile layout, in place of the default one. */
|
||||
widgets?: Parameters<typeof UserRepository.upsertWidgets>[1];
|
||||
/** Preferences, merged into the ones the user has, as the settings pages save them. */
|
||||
preferences?: UserPreferences;
|
||||
@@ -148,18 +145,12 @@ async function currentProfile(userId: number): Promise<ProfileArgs> {
|
||||
.selectFrom("User")
|
||||
.select([
|
||||
"country",
|
||||
"bio",
|
||||
"customUrl",
|
||||
"customName",
|
||||
"motionSens",
|
||||
"stickSens",
|
||||
"pronouns",
|
||||
"inGameName",
|
||||
"battlefy",
|
||||
"showDiscordUniqueName",
|
||||
"commissionText",
|
||||
"commissionsOpen",
|
||||
"favoriteBadgeIds",
|
||||
"favoriteTrophyIds",
|
||||
"hiddenTrophyIds",
|
||||
"customAvatarImgId",
|
||||
@@ -167,17 +158,9 @@ async function currentProfile(userId: number): Promise<ProfileArgs> {
|
||||
.where("id", "=", userId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const weapons = await db
|
||||
.selectFrom("UserWeapon")
|
||||
.select(["weaponSplId", "isFavorite"])
|
||||
.where("userId", "=", userId)
|
||||
.orderBy("order", "asc")
|
||||
.execute();
|
||||
|
||||
return {
|
||||
...user,
|
||||
pronouns: user.pronouns ? JSON.stringify(user.pronouns) : null,
|
||||
weapons,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -231,25 +214,8 @@ function fakeProfile(): Partial<ProfileArgs> | null {
|
||||
|
||||
return {
|
||||
country: fakeCountry(),
|
||||
bio: chance(0.4)
|
||||
? faker.lorem.paragraphs(faker.helpers.arrayElement([1, 1, 2, 3]), "\n\n")
|
||||
: undefined,
|
||||
inGameName: chance(0.5) ? SplatoonFaker.inGameName() : undefined,
|
||||
motionSens: chance(0.3)
|
||||
? faker.helpers.arrayElement([-50, -30, -10, 0, 10, 30, 50])
|
||||
: undefined,
|
||||
stickSens: chance(0.3)
|
||||
? faker.helpers.arrayElement([-50, -20, 0, 20, 50])
|
||||
: undefined,
|
||||
pronouns: chance(0.2) ? fakePronouns() : undefined,
|
||||
weapons: chance(0.6)
|
||||
? SplatoonFaker.mainWeapons(
|
||||
faker.helpers.arrayElement([1, 2, 3, 4, 5]),
|
||||
).map((weaponSplId) => ({
|
||||
weaponSplId,
|
||||
isFavorite: toDBBoolean(faker.number.float(1) < 0.2),
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -354,7 +320,6 @@ export async function grant(
|
||||
matchProfile,
|
||||
ban,
|
||||
div,
|
||||
weapons,
|
||||
card,
|
||||
widgets,
|
||||
preferences,
|
||||
@@ -401,11 +366,6 @@ export async function grant(
|
||||
await UserRepository.updateManyDivs([{ userId, div }]);
|
||||
}
|
||||
|
||||
if (weapons) {
|
||||
// the profile page saves every field at once; the rest is still empty on a fresh upsert
|
||||
await actAs(userId, () => UserRepository.updateOwnProfile({ weapons }));
|
||||
}
|
||||
|
||||
if (widgets) {
|
||||
await UserRepository.upsertWidgets(userId, widgets);
|
||||
}
|
||||
|
||||
@@ -51,8 +51,6 @@ export interface UserPreferences {
|
||||
defaultScrimsFilters?: ScrimFilters;
|
||||
/** "auto" (default) = browser default */
|
||||
clockFormat?: "24h" | "12h" | "auto";
|
||||
/** Widget based user page (supporter early preview) */
|
||||
newProfileEnabled?: boolean;
|
||||
/** Hides recent tournament results and scores until revealed */
|
||||
spoilerFreeMode?: boolean;
|
||||
weaponReportDefaultOpen?: boolean;
|
||||
|
||||
@@ -947,8 +947,6 @@ export interface User {
|
||||
/** 1 = permabanned, timestamp = ban active till then */
|
||||
banned: Generated<number | null>;
|
||||
bannedReason: string | null;
|
||||
/** Shown on old user profile and Plus Voting */
|
||||
bio: string | null;
|
||||
/** Shown on user card */
|
||||
shortBio: string | null;
|
||||
commissionsOpen: Generated<DBBoolean>;
|
||||
@@ -967,8 +965,6 @@ export interface User {
|
||||
/** Name the user is shown under in tournaments, set by organizers of established organizations. `null` = their `username` is used. */
|
||||
tournamentName: string | null;
|
||||
discordUniqueName: string | null;
|
||||
/** User's favorite badges they want to show on the front page of the badge display. Index = 0 big badge. */
|
||||
favoriteBadgeIds: JSONColumnTypeNullable<number[]>;
|
||||
favoriteTrophyIds: JSONColumnTypeNullable<number[]>;
|
||||
hiddenTrophyIds: JSONColumnTypeNullable<number[]>;
|
||||
id: GeneratedAlways<number>;
|
||||
@@ -978,16 +974,12 @@ export interface User {
|
||||
isTournamentOrganizer: Generated<DBBoolean>;
|
||||
isApiAccesser: Generated<DBBoolean>;
|
||||
languages: JSONColumnTypeNullable<UnifiedLanguageCode[]>;
|
||||
motionSens: number | null;
|
||||
pronouns: JSONColumnTypeNullable<Pronouns>;
|
||||
patronStartedAt: number | null;
|
||||
patronTier: number | null;
|
||||
patronExpiresAt: number | null;
|
||||
showDiscordUniqueName: Generated<DBBoolean>;
|
||||
stickSens: number | null;
|
||||
twitch: string | null;
|
||||
bsky: string | null;
|
||||
battlefy: string | null;
|
||||
vc: Generated<"YES" | "NO" | "LISTEN_ONLY">;
|
||||
youtubeId: string | null;
|
||||
mapModePreferences: JSONColumnTypeNullable<UserMapModePreferences>;
|
||||
@@ -1038,14 +1030,6 @@ export interface UserSearch {
|
||||
customUrl: GeneratedAlways<string | null>;
|
||||
}
|
||||
|
||||
export interface UserWeapon {
|
||||
createdAt: Generated<number>;
|
||||
isFavorite: Generated<DBBoolean>;
|
||||
order: number;
|
||||
userId: number;
|
||||
weaponSplId: MainWeaponId;
|
||||
}
|
||||
|
||||
export interface UserWeaponPool {
|
||||
userId: number;
|
||||
sortOrder: number;
|
||||
@@ -1479,7 +1463,6 @@ export interface DB {
|
||||
UserResultHighlight: UserResultHighlight;
|
||||
/** VIEW over `UnvalidatedUserSubmittedImage`, excludes images awaiting validation. Insert/update via `UnvalidatedUserSubmittedImage`. */
|
||||
UserSubmittedImage: UserSubmittedImage;
|
||||
UserWeapon: UserWeapon;
|
||||
UserWeaponPool: UserWeaponPool;
|
||||
TenStarWeapon: TenStarWeapon;
|
||||
UserFriendCode: UserFriendCode;
|
||||
|
||||
@@ -22,10 +22,6 @@ export function migrate(args: { newUserId: number; oldUserId: number }) {
|
||||
|
||||
// small data on the new account is dropped so it doesn't block the migration;
|
||||
// bigger things (e.g. played tournaments) still fail validation
|
||||
await trx
|
||||
.deleteFrom("UserWeapon")
|
||||
.where("userId", "=", args.newUserId)
|
||||
.execute();
|
||||
await trx
|
||||
.deleteFrom("Build")
|
||||
.where("ownerId", "=", args.newUserId)
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import { db } from "~/db/sql";
|
||||
import * as BuildRepository from "~/features/builds/BuildRepository.server";
|
||||
import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants";
|
||||
import * as MatchProfileRepository from "~/features/match-profile/MatchProfileRepository.server";
|
||||
import * as TeamRepository from "~/features/team/TeamRepository.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { assertResponseErrored, wrappedAction } from "~/utils/Test";
|
||||
@@ -339,19 +340,22 @@ describe("Account migration", () => {
|
||||
expect(membershipNewUser).toBeUndefined();
|
||||
});
|
||||
|
||||
test("deletes weapon pool from the new user when migrating (takes weapon pool from the old user)", async () => {
|
||||
test("keeps the match profile weapon pool of the old user when migrating", async () => {
|
||||
await UserFactory.grant(users.id(1), {
|
||||
weapons: [{ weaponSplId: 1, isFavorite: 1 }],
|
||||
matchProfile: { weaponPool: [{ id: 1, isFavorite: true }] },
|
||||
});
|
||||
await UserFactory.grant(users.id(2), {
|
||||
matchProfile: { weaponPool: [{ id: 10, isFavorite: false }] },
|
||||
});
|
||||
await UserFactory.grant(users.id(2), { weapons: [{ weaponSplId: 10 }] });
|
||||
|
||||
await migrateUserAction();
|
||||
|
||||
const oldUser = await UserRepository.findProfileByIdentifier("0");
|
||||
const newUser = await UserRepository.findProfileByIdentifier("1");
|
||||
const migratedUser = await MatchProfileRepository.findSettingsByUserId(
|
||||
users.id(1),
|
||||
);
|
||||
|
||||
expect(oldUser).toBeNull();
|
||||
expect(newUser?.weapons).toEqual([
|
||||
expect(await UserRepository.findProfileByIdentifier("0")).toBeNull();
|
||||
expect(migratedUser.weaponPool).toEqual([
|
||||
{ weaponSplId: 1, isFavorite: 1, isTenStar: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -105,7 +105,6 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
tournamentUsername().as("username"),
|
||||
"User.discordId",
|
||||
"User.discordAvatar",
|
||||
"User.battlefy",
|
||||
"User.country",
|
||||
"User.pronouns",
|
||||
"TournamentTeamMember.inGameName",
|
||||
@@ -166,7 +165,6 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
return {
|
||||
userId: member.userId,
|
||||
name: member.username,
|
||||
battlefy: member.battlefy,
|
||||
discordId: member.discordId,
|
||||
avatarUrl: member.discordAvatar
|
||||
? `https://cdn.discordapp.com/avatars/${member.discordId}/${member.discordAvatar}.png`
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import * as v from "valibot";
|
||||
import { db } from "~/db/sql";
|
||||
import * as BadgeRepository from "~/features/badges/BadgeRepository.server";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import { userSkills as _userSkills } from "~/features/mmr/tiered.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { getFixedTForLanguage } from "~/modules/i18n/i18next.server";
|
||||
import { jsonArrayFrom, peakXpOverallSql } from "~/utils/kysely.server";
|
||||
import { safeNumberParse } from "~/utils/number";
|
||||
@@ -29,7 +29,6 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
"User.country",
|
||||
"User.discordName",
|
||||
"User.twitch",
|
||||
"User.battlefy",
|
||||
"User.bsky",
|
||||
"User.customUrl",
|
||||
"User.discordId",
|
||||
@@ -39,10 +38,10 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
"PlusTier.tier",
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("UserWeapon")
|
||||
.select(["UserWeapon.isFavorite", "UserWeapon.weaponSplId"])
|
||||
.whereRef("UserWeapon.userId", "=", "User.id")
|
||||
.orderBy("UserWeapon.order", "asc"),
|
||||
.selectFrom("UserWeaponPool")
|
||||
.select(["UserWeaponPool.isFavorite", "UserWeaponPool.weaponSplId"])
|
||||
.whereRef("UserWeaponPool.userId", "=", "User.id")
|
||||
.orderBy("UserWeaponPool.sortOrder", "asc"),
|
||||
).as("weapons"),
|
||||
peakXpOverallSql().as("peakXp"),
|
||||
jsonArrayFrom(
|
||||
@@ -68,7 +67,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
.executeTakeFirst(),
|
||||
);
|
||||
|
||||
const badges = await UserRepository.findOwnedBadgesByUserId(user.id);
|
||||
const badges = await BadgeRepository.findByOwnerUserId(user.id, []);
|
||||
|
||||
const season = Seasons.currentOrPrevious(new Date())!.nth;
|
||||
|
||||
@@ -89,7 +88,6 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
plusServerTier: user.tier as GetUserResponse["plusServerTier"],
|
||||
socials: {
|
||||
twitch: user.twitch,
|
||||
battlefy: user.battlefy,
|
||||
bsky: user.bsky,
|
||||
twitter: null, // deprecated field
|
||||
},
|
||||
|
||||
@@ -20,7 +20,6 @@ export interface GetUserResponse {
|
||||
twitch: string | null;
|
||||
/** @deprecated */
|
||||
twitter: null;
|
||||
battlefy: string | null;
|
||||
bsky: string | null;
|
||||
};
|
||||
plusServerTier: 1 | 2 | 3 | null;
|
||||
@@ -162,8 +161,6 @@ export type GetTournamentTeamsResponse = Array<{
|
||||
name: string;
|
||||
/** @example "79237403620945920" */
|
||||
discordId: string;
|
||||
/** @example "sendouc" */
|
||||
battlefy: string | null;
|
||||
/** @example "https://cdn.discordapp.com/avatars/79237403620945920/6fc41a44b069a0d2152ac06d1e496c6c.png" */
|
||||
avatarUrl: string | null;
|
||||
/** @example "FI" */
|
||||
|
||||
@@ -130,7 +130,14 @@ export function findManagedByUserId(userId: number) {
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function findByOwnerUserId(userId: number) {
|
||||
/**
|
||||
* Takes a constant userId on purpose: correlating to an outer "User"."id" would stop SQLite
|
||||
* pushing the predicate into both arms of the BadgeOwner view, materializing the full view.
|
||||
*/
|
||||
export async function findByOwnerUserId(
|
||||
userId: number,
|
||||
favoriteBadgeIds: number[],
|
||||
) {
|
||||
const rows = await db
|
||||
.selectFrom("BadgeOwner")
|
||||
.innerJoin("Badge", "Badge.id", "BadgeOwner.badgeId")
|
||||
@@ -141,7 +148,6 @@ export async function findByOwnerUserId(userId: number) {
|
||||
"Badge.displayName",
|
||||
"Badge.code",
|
||||
"Badge.hue",
|
||||
"User.favoriteBadgeIds",
|
||||
"User.patronTier",
|
||||
])
|
||||
.where("BadgeOwner.userId", "=", userId)
|
||||
@@ -150,15 +156,11 @@ export async function findByOwnerUserId(userId: number) {
|
||||
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const { favoriteBadgeIds, patronTier } = rows[0];
|
||||
|
||||
return sortBadgesByFavorites({
|
||||
favoriteBadgeIds,
|
||||
badges: rows.map(
|
||||
({ favoriteBadgeIds: _, patronTier: __, ...badge }) => badge,
|
||||
),
|
||||
patronTier,
|
||||
}).badges;
|
||||
badges: rows.map(({ patronTier: _, ...badge }) => badge),
|
||||
patronTier: rows[0].patronTier,
|
||||
});
|
||||
}
|
||||
|
||||
export function findByAuthorUserId(userId: number) {
|
||||
|
||||
@@ -86,6 +86,16 @@ const PERKS = [
|
||||
name: "customizedColorsUser",
|
||||
extraInfo: false,
|
||||
},
|
||||
{
|
||||
tier: 2,
|
||||
name: "supporterWidgets",
|
||||
extraInfo: true,
|
||||
},
|
||||
{
|
||||
tier: 2,
|
||||
name: "moreWidgets",
|
||||
extraInfo: true,
|
||||
},
|
||||
{
|
||||
tier: 2,
|
||||
name: "customAvatar",
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
concatUserSubmittedImagePrefix,
|
||||
jsonArrayFrom,
|
||||
jsonObjectFrom,
|
||||
userProfileWeapons,
|
||||
matchProfileWeapons,
|
||||
} from "~/utils/kysely.server";
|
||||
import { LFG } from "./lfg-constants";
|
||||
|
||||
@@ -39,7 +39,7 @@ export async function findAllPosts(user?: {
|
||||
"User.languages",
|
||||
"User.country",
|
||||
"PlusTier.tier as plusTier",
|
||||
userProfileWeapons(innerEb).as("weaponPool"),
|
||||
matchProfileWeapons(innerEb).as("weaponPool"),
|
||||
])
|
||||
.whereRef("User.id", "=", "LFGPost.authorId"),
|
||||
).as("author"),
|
||||
@@ -67,7 +67,7 @@ export async function findAllPosts(user?: {
|
||||
"User.languages",
|
||||
"User.country",
|
||||
"PlusTier.tier as plusTier",
|
||||
userProfileWeapons(innestEb).as("weaponPool"),
|
||||
matchProfileWeapons(innestEb).as("weaponPool"),
|
||||
])
|
||||
.whereRef("TeamMemberWithSecondary.teamId", "=", "Team.id"),
|
||||
).as("members"),
|
||||
|
||||
@@ -20,7 +20,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
|
||||
return {
|
||||
team: userProfileData?.team,
|
||||
weaponPool: userProfileData?.weapons,
|
||||
weaponPool: userMatchProfile.weaponPool,
|
||||
languages: postToEdit?.languages ?? userMatchProfile.languages,
|
||||
postToEdit,
|
||||
userPostTypes: userPostTypes(allPosts, user.id),
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { UnifiedLanguageCode } from "~/modules/i18n/config";
|
||||
import { useHasRole } from "~/modules/permissions/hooks";
|
||||
import { metaTags, ogPageImage } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { LFG_PAGE, navIconUrl, userEditProfilePage } from "~/utils/urls";
|
||||
import { LFG_PAGE, MATCH_PROFILE_PAGE, navIconUrl } from "~/utils/urls";
|
||||
import { action } from "../actions/lfg.new.server";
|
||||
import { LFG, TEAM_POST_TYPES, TIMEZONES } from "../lfg-constants";
|
||||
import { lfgNewSchema } from "../lfg-schemas";
|
||||
@@ -143,7 +143,6 @@ function ConditionalWeaponPool() {
|
||||
|
||||
function WeaponPool() {
|
||||
const { t } = useTranslation(["lfg"]);
|
||||
const user = useUser();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
@@ -161,8 +160,8 @@ function WeaponPool() {
|
||||
</div>
|
||||
<FormMessage type="info">
|
||||
{t("lfg:new.editOn")}{" "}
|
||||
<Link to={userEditProfilePage(user!)}>
|
||||
{t("lfg:new.weaponPool.userProfile")}
|
||||
<Link to={MATCH_PROFILE_PAGE}>
|
||||
{t("lfg:new.weaponPool.matchProfile")}
|
||||
</Link>
|
||||
</FormMessage>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,16 @@ export function findSettingsByUserId(userId: number) {
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
/** Match profile weapon pool of one user, with ten-star status. */
|
||||
export function findWeaponPoolByUserId(userId: number) {
|
||||
return db
|
||||
.selectFrom("User")
|
||||
.select(({ eb }) => matchProfileWeapons(eb).as("weaponPool"))
|
||||
.where("User.id", "=", userId)
|
||||
.executeTakeFirstOrThrow()
|
||||
.then((row) => row.weaponPool);
|
||||
}
|
||||
|
||||
export async function updateOwnMatchProfile({
|
||||
mapModePreferences,
|
||||
vc,
|
||||
|
||||
77
app/features/plus-voting/PlusVotingRepository.server.test.ts
Normal file
77
app/features/plus-voting/PlusVotingRepository.server.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import { pinClockInsideSeason } from "~/features/sendouq/tests/season-clock";
|
||||
import type { StoredWidget } from "~/features/user-page/core/widgets/types";
|
||||
import * as PlusVotingRepository from "./PlusVotingRepository.server";
|
||||
|
||||
const PLUS_TIER = 1;
|
||||
|
||||
const users = UserFactory.pool();
|
||||
|
||||
const voterId = () => users.id(1);
|
||||
const votedOnId = () => users.id(2);
|
||||
|
||||
describe("PlusVotingRepository.findAllUsersForVoting", () => {
|
||||
pinClockInsideSeason();
|
||||
|
||||
beforeEach(async () => {
|
||||
await users.create(2, null, { plusTier: PLUS_TIER });
|
||||
});
|
||||
|
||||
const bioOf = async (widgets: StoredWidget[]) => {
|
||||
await UserFactory.grant(votedOnId(), { widgets });
|
||||
|
||||
const usersForVoting = await PlusVotingRepository.findAllUsersForVoting({
|
||||
id: voterId(),
|
||||
plusTier: PLUS_TIER,
|
||||
});
|
||||
|
||||
return (
|
||||
usersForVoting.find(({ user }) => user.id === votedOnId())?.user.bio ??
|
||||
null
|
||||
);
|
||||
};
|
||||
|
||||
test("returns the text of the bio widget", async () => {
|
||||
const bio = await bioOf([{ id: "bio", settings: { bio: "gg" } }]);
|
||||
|
||||
expect(bio).toEqual({ text: "gg", markdown: false });
|
||||
});
|
||||
|
||||
test("marks the text of the markdown bio widget as markdown", async () => {
|
||||
const bio = await bioOf([{ id: "bio-md", settings: { bio: "**gg**" } }]);
|
||||
|
||||
expect(bio).toEqual({ text: "**gg**", markdown: true });
|
||||
});
|
||||
|
||||
test("returns the bio widget higher up the profile when there are two", async () => {
|
||||
const bio = await bioOf([
|
||||
{ id: "bio-md", settings: { bio: "**gg**" } },
|
||||
{ id: "bio", settings: { bio: "gg" } },
|
||||
]);
|
||||
|
||||
expect(bio).toEqual({ text: "**gg**", markdown: true });
|
||||
});
|
||||
|
||||
test("returns no bio for a user without a bio widget", async () => {
|
||||
const bio = await bioOf([{ id: "weapon-pool" }]);
|
||||
|
||||
expect(bio).toBeNull();
|
||||
});
|
||||
|
||||
test("returns no bio for an empty bio widget", async () => {
|
||||
const bio = await bioOf([{ id: "bio", settings: { bio: "" } }]);
|
||||
|
||||
expect(bio).toBeNull();
|
||||
});
|
||||
|
||||
// the voting page renders the bio as a React child; an object there would error the page
|
||||
test("keeps a JSON-object-shaped bio a string", async () => {
|
||||
const bio = await bioOf([
|
||||
{ id: "bio", settings: { bio: '{"note":"gg"}' } },
|
||||
]);
|
||||
|
||||
expect(bio).toEqual({ text: '{"note":"gg"}', markdown: false });
|
||||
expect(typeof bio?.text).toBe("string");
|
||||
});
|
||||
});
|
||||
@@ -124,11 +124,13 @@ function groupPlusVotingResults(rows: EnrichedRow[]) {
|
||||
.sort((a, b) => a.tier - b.tier);
|
||||
}
|
||||
|
||||
type Bio = { text: string; markdown: boolean };
|
||||
|
||||
export type UsersForVoting = {
|
||||
user: Pick<
|
||||
Tables["User"],
|
||||
"id" | "discordId" | "username" | "discordAvatar" | "bio"
|
||||
> & { customAvatarUrl: string | null };
|
||||
"id" | "discordId" | "username" | "discordAvatar"
|
||||
> & { customAvatarUrl: string | null; bio: Bio | null };
|
||||
suggestion?: PlusSuggestionRepository.FindAllByMonthItem;
|
||||
}[];
|
||||
|
||||
@@ -139,7 +141,7 @@ export async function findAllUsersForVoting(loggedInUser: {
|
||||
const members = await db
|
||||
.selectFrom("User")
|
||||
.innerJoin("PlusTier", "PlusTier.userId", "User.id")
|
||||
.select((eb) => [...commonUserSelect(eb), "User.bio"])
|
||||
.select((eb) => commonUserSelect(eb))
|
||||
.where("PlusTier.tier", "=", loggedInUser.plusTier)
|
||||
.execute();
|
||||
|
||||
@@ -152,9 +154,10 @@ export async function findAllUsersForVoting(loggedInUser: {
|
||||
});
|
||||
|
||||
// bios are not part of a suggestion (the suggestions page does not render them)
|
||||
const suggestedUserBios = await findBiosByUserIds(
|
||||
suggestedUsers.map((suggestion) => suggestion.suggested.id),
|
||||
);
|
||||
const bios = await findBiosByUserIds([
|
||||
...members.map((member) => member.id),
|
||||
...suggestedUsers.map((suggestion) => suggestion.suggested.id),
|
||||
]);
|
||||
|
||||
const result: UsersForVoting = [];
|
||||
|
||||
@@ -166,7 +169,7 @@ export async function findAllUsersForVoting(loggedInUser: {
|
||||
username: member.username,
|
||||
discordAvatar: member.discordAvatar,
|
||||
customAvatarUrl: member.customAvatarUrl,
|
||||
bio: member.bio,
|
||||
bio: bios.get(member.id) ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -179,7 +182,7 @@ export async function findAllUsersForVoting(loggedInUser: {
|
||||
username: suggestion.suggested.username,
|
||||
discordAvatar: suggestion.suggested.discordAvatar,
|
||||
customAvatarUrl: suggestion.suggested.customAvatarUrl,
|
||||
bio: suggestedUserBios.get(suggestion.suggested.id) ?? null,
|
||||
bio: bios.get(suggestion.suggested.id) ?? null,
|
||||
},
|
||||
suggestion,
|
||||
});
|
||||
@@ -229,14 +232,42 @@ export function upsertMany(votes: UpsertManyPlusVotesArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Bios as the profile page's bio widget stores them, keyed by user id. */
|
||||
async function findBiosByUserIds(userIds: number[]) {
|
||||
if (userIds.length === 0) return new Map<number, string | null>();
|
||||
const bios = new Map<number, Bio>();
|
||||
|
||||
if (userIds.length === 0) return bios;
|
||||
|
||||
const rows = await db
|
||||
.selectFrom("User")
|
||||
.select(["User.id", "User.bio"])
|
||||
.where("User.id", "in", userIds)
|
||||
.selectFrom("UserWidget")
|
||||
.select([
|
||||
"UserWidget.userId",
|
||||
// cast keeps a bio that happens to look like JSON a string, the dialect
|
||||
// parses raw selections starting with `json` as documents
|
||||
sql<
|
||||
string | null
|
||||
>`cast(json_extract("UserWidget"."widget", '$.settings.bio') as text)`.as(
|
||||
"bio",
|
||||
),
|
||||
sql<string>`json_extract("UserWidget"."widget", '$.id')`.as("widgetId"),
|
||||
])
|
||||
.where("UserWidget.userId", "in", userIds)
|
||||
.where(sql`json_extract("UserWidget"."widget", '$.id')`, "in", [
|
||||
"bio",
|
||||
"bio-md",
|
||||
])
|
||||
.orderBy("UserWidget.index", "asc")
|
||||
.execute();
|
||||
|
||||
return new Map(rows.map((row) => [row.id, row.bio]));
|
||||
for (const row of rows) {
|
||||
// a user can have both bio widgets, the one higher up their profile wins
|
||||
if (row.bio && !bios.has(row.userId)) {
|
||||
bios.set(row.userId, {
|
||||
text: row.bio,
|
||||
markdown: row.widgetId === "bio-md",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return bios;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { MetaFunction } from "react-router";
|
||||
import { Form, useLoaderData } from "react-router";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { Markdown } from "~/components/Markdown";
|
||||
import { RelativeTime } from "~/components/RelativeTime";
|
||||
import { usePlusVoting } from "~/features/plus-voting/core";
|
||||
import { UserCard } from "~/features/user-card/components/UserCard";
|
||||
@@ -161,7 +162,11 @@ function Voting(data: Extract<PlusVotingLoaderData, { type: "voting" }>) {
|
||||
{currentUser.user.bio ? (
|
||||
<article className="w-full">
|
||||
<h2 className={styles.votingBioHeader}>Bio</h2>
|
||||
{currentUser.user.bio}
|
||||
{currentUser.user.bio.markdown ? (
|
||||
<Markdown>{currentUser.user.bio.text}</Markdown>
|
||||
) : (
|
||||
currentUser.user.bio.text
|
||||
)}
|
||||
</article>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
commonUserSelect,
|
||||
concatUserSubmittedImagePrefix,
|
||||
jsonArrayFrom,
|
||||
matchProfileWeapons,
|
||||
tournamentLogoOrNull,
|
||||
userProfileWeapons,
|
||||
} from "~/utils/kysely.server";
|
||||
import { toDBBoolean } from "~/utils/sql";
|
||||
import { mySlugify } from "~/utils/urls";
|
||||
@@ -168,7 +168,7 @@ export async function findByCustomUrl(
|
||||
"TeamMemberWithSecondary.isMainTeam",
|
||||
"User.country",
|
||||
"User.patronTier",
|
||||
userProfileWeapons(innerEb).as("weapons"),
|
||||
matchProfileWeapons(innerEb).as("weapons"),
|
||||
])
|
||||
.whereRef("TeamMemberWithSecondary.teamId", "=", "Team.id")
|
||||
.orderBy("TeamMemberWithSecondary.order", "asc"),
|
||||
|
||||
@@ -68,6 +68,21 @@ describe("team page editing", () => {
|
||||
await UserFactory.createRegular(null, { patronTier: 2 });
|
||||
});
|
||||
|
||||
describe("bio", () => {
|
||||
beforeEach(() => createTeam());
|
||||
|
||||
test("keeps a JSON-object-shaped bio as text (not a parsed object)", async () => {
|
||||
await editTeamProfileAction(
|
||||
// a bio the user typed that happens to be valid JSON of object shape
|
||||
{ ...DEFAULT_EDIT_FIELDS, bio: '{"note":"gg"}' },
|
||||
{ user: "regular", params: { customUrl } },
|
||||
);
|
||||
|
||||
// the team page renders bio directly as a React child; an object would 500 the page
|
||||
expect(typeof (await teamRow()).bio).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom theme", () => {
|
||||
beforeEach(() => createTeam());
|
||||
|
||||
|
||||
@@ -343,7 +343,8 @@ describe("existsByName", () => {
|
||||
describe("user deletion", () => {
|
||||
test("keeps their trophies and drops their approvals", async () => {
|
||||
const submitter = await UserFactory.create();
|
||||
const deleted = await UserFactory.create();
|
||||
// bare: a random profile's weapon pool would block the delete on its own
|
||||
const deleted = await UserFactory.create({ profile: null });
|
||||
|
||||
const trophy = await TrophyFactory.create({
|
||||
name: "Orphaned Trophy",
|
||||
|
||||
@@ -32,13 +32,15 @@ import {
|
||||
jsonObjectFrom,
|
||||
tournamentLogoOrNull,
|
||||
userByIdentifierQuery,
|
||||
userProfileWeapons,
|
||||
} from "~/utils/kysely.server";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { seededRandom } from "~/utils/random";
|
||||
import { bskyUrl, twitchUrl, youtubeUrl } from "~/utils/urls";
|
||||
import { sortBadgesByFavorites } from "./core/badge-sorting.server";
|
||||
import { findWidgetById } from "./core/widgets/portfolio";
|
||||
import {
|
||||
DEFAULT_WIDGETS,
|
||||
findWidgetById,
|
||||
widgetsAvailableTo,
|
||||
} from "./core/widgets/portfolio";
|
||||
import { WIDGET_LOADERS } from "./core/widgets/portfolio-loaders.server";
|
||||
import type { LoadedWidget } from "./core/widgets/types";
|
||||
import { SPL2_JOIN_ORDER_CUTOFF } from "./user-page-constants";
|
||||
@@ -81,10 +83,10 @@ export async function findBuildFieldsByIdentifier(identifier: string) {
|
||||
"User.buildSorting",
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("UserWeapon")
|
||||
.select("UserWeapon.weaponSplId")
|
||||
.whereRef("UserWeapon.userId", "=", "User.id")
|
||||
.orderBy("UserWeapon.order", "asc"),
|
||||
.selectFrom("UserWeaponPool")
|
||||
.select("UserWeaponPool.weaponSplId")
|
||||
.whereRef("UserWeaponPool.userId", "=", "User.id")
|
||||
.orderBy("UserWeaponPool.sortOrder", "asc"),
|
||||
).as("weapons"),
|
||||
])
|
||||
.executeTakeFirst();
|
||||
@@ -180,27 +182,18 @@ export function findLayoutDataByIdentifier(
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
export async function findProfileByIdentifier(
|
||||
identifier: string,
|
||||
forceShowDiscordUniqueName?: boolean,
|
||||
) {
|
||||
export async function findProfileByIdentifier(identifier: string) {
|
||||
const row = await userByIdentifierQuery(identifier)
|
||||
.leftJoin("PlusTier", "PlusTier.userId", "User.id")
|
||||
.select(({ eb }) => [
|
||||
"User.twitch",
|
||||
"User.youtubeId",
|
||||
"User.battlefy",
|
||||
"User.bsky",
|
||||
"User.country",
|
||||
"User.bio",
|
||||
"User.motionSens",
|
||||
"User.stickSens",
|
||||
"User.inGameName",
|
||||
"User.customName",
|
||||
"User.discordName",
|
||||
"User.showDiscordUniqueName",
|
||||
"User.discordUniqueName",
|
||||
"User.favoriteBadgeIds",
|
||||
"User.favoriteTrophyIds",
|
||||
"User.hiddenTrophyIds",
|
||||
"User.patronTier",
|
||||
@@ -208,7 +201,6 @@ export async function findProfileByIdentifier(
|
||||
"User.pronouns",
|
||||
"User.customAvatarImgId",
|
||||
customAvatarUrl(eb).as("customAvatarUrl"),
|
||||
userProfileWeapons(eb).as("weapons"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("TeamMemberWithSecondary")
|
||||
@@ -231,23 +223,6 @@ export async function findProfileByIdentifier(
|
||||
])
|
||||
.whereRef("TeamMemberWithSecondary.userId", "=", "User.id"),
|
||||
).as("teams"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("SplatoonPlayer")
|
||||
.innerJoin(
|
||||
"XRankPlacement",
|
||||
"XRankPlacement.playerId",
|
||||
"SplatoonPlayer.id",
|
||||
)
|
||||
.select(({ fn }) => [
|
||||
"XRankPlacement.mode",
|
||||
fn.max<number>("XRankPlacement.power").as("power"),
|
||||
fn.min<number>("XRankPlacement.rank").as("rank"),
|
||||
"XRankPlacement.playerId",
|
||||
])
|
||||
.whereRef("SplatoonPlayer.userId", "=", "User.id")
|
||||
.groupBy(["XRankPlacement.mode"]),
|
||||
).as("topPlacements"),
|
||||
])
|
||||
.executeTakeFirst();
|
||||
|
||||
@@ -255,63 +230,14 @@ export async function findProfileByIdentifier(
|
||||
return null;
|
||||
}
|
||||
|
||||
// queried separately with a constant userId, see findOwnedBadgesByUserId
|
||||
const badges = await findOwnedBadgesByUserId(row.id);
|
||||
|
||||
return {
|
||||
...row,
|
||||
team: row.teams.find((t) => t.isMainTeam),
|
||||
secondaryTeams: row.teams.filter((t) => !t.isMainTeam),
|
||||
teams: undefined,
|
||||
...sortBadgesByFavorites({ ...row, badges }),
|
||||
discordUniqueName:
|
||||
forceShowDiscordUniqueName || row.showDiscordUniqueName
|
||||
? row.discordUniqueName
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a constant userId on purpose: correlating to an outer "User"."id" would stop SQLite
|
||||
* pushing the predicate into both arms of the BadgeOwner view, materializing the full view.
|
||||
*/
|
||||
export function findOwnedBadgesByUserId(userId: number) {
|
||||
return db
|
||||
.selectFrom("BadgeOwner")
|
||||
.innerJoin("Badge", "Badge.id", "BadgeOwner.badgeId")
|
||||
.select(({ fn }) => [
|
||||
fn.sum<number>("BadgeOwner.count").as("count"),
|
||||
"Badge.id",
|
||||
"Badge.displayName",
|
||||
"Badge.code",
|
||||
"Badge.hue",
|
||||
])
|
||||
.where("BadgeOwner.userId", "=", userId)
|
||||
.groupBy("BadgeOwner.badgeId")
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function findEnabledWidgetsByIdentifier(identifier: string) {
|
||||
const row = await userByIdentifierQuery(identifier)
|
||||
.select(["User.preferences", "User.patronTier"])
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!row) return false;
|
||||
if (!isSupporter(row)) return false;
|
||||
|
||||
return row?.preferences?.newProfileEnabled === true;
|
||||
}
|
||||
|
||||
export async function findPreferencesByUserId(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"]>,
|
||||
@@ -337,42 +263,37 @@ export async function findStoredWidgetsByUserId(
|
||||
): Promise<Array<Tables["UserWidget"]["widget"]>> {
|
||||
const rows = await db
|
||||
.selectFrom("UserWidget")
|
||||
.select(["widget"])
|
||||
.where("userId", "=", userId)
|
||||
.orderBy("index", "asc")
|
||||
.innerJoin("User", "User.id", "UserWidget.userId")
|
||||
.select(["UserWidget.widget", "User.patronTier"])
|
||||
.where("UserWidget.userId", "=", userId)
|
||||
.orderBy("UserWidget.index", "asc")
|
||||
.execute();
|
||||
|
||||
return rows.map((row) => row.widget);
|
||||
if (rows.length === 0) return DEFAULT_WIDGETS;
|
||||
|
||||
return widgetsAvailableTo(
|
||||
rows.map((row) => row.widget),
|
||||
isSupporter({ patronTier: rows[0]!.patronTier }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function findWidgetsByUserId(
|
||||
identifier: string,
|
||||
): Promise<LoadedWidget[] | null> {
|
||||
const user = await findIdByIdentifier(identifier);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const widgets = await db
|
||||
.selectFrom("UserWidget")
|
||||
.select(["widget"])
|
||||
.where("userId", "=", user.id)
|
||||
.orderBy("index", "asc")
|
||||
.execute();
|
||||
userId: number,
|
||||
): Promise<LoadedWidget[]> {
|
||||
const widgets = await findStoredWidgetsByUserId(userId);
|
||||
|
||||
const loadedWidgets = await Promise.all(
|
||||
widgets.map(async ({ widget }) => {
|
||||
widgets.map(async (widget) => {
|
||||
const definition = findWidgetById(widget.id);
|
||||
|
||||
if (!definition) {
|
||||
logger.warn(
|
||||
`Unknown widget id found for user ${user.id}: ${widget.id}`,
|
||||
);
|
||||
logger.warn(`Unknown widget id found for user ${userId}: ${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)
|
||||
? await loader(userId, widget.settings as any)
|
||||
: widget.settings;
|
||||
|
||||
return {
|
||||
@@ -1006,13 +927,7 @@ const searchSelectedFields = (eb: ExpressionBuilder<DB, "User">) =>
|
||||
"User.inGameName",
|
||||
"User.tournamentName",
|
||||
"PlusTier.tier as plusTier",
|
||||
eb
|
||||
.fn<string | null>("iif", [
|
||||
"User.showDiscordUniqueName",
|
||||
"User.discordUniqueName",
|
||||
sql`null`,
|
||||
])
|
||||
.as("discordUniqueName"),
|
||||
"User.discordUniqueName",
|
||||
] as const;
|
||||
export async function search({
|
||||
query,
|
||||
@@ -1277,20 +1192,13 @@ export function upsert(
|
||||
type UpdateProfileArgs = Pick<
|
||||
TablesInsertable["User"],
|
||||
| "country"
|
||||
| "bio"
|
||||
| "customUrl"
|
||||
| "customName"
|
||||
| "motionSens"
|
||||
| "stickSens"
|
||||
| "pronouns"
|
||||
| "inGameName"
|
||||
| "battlefy"
|
||||
| "showDiscordUniqueName"
|
||||
| "commissionText"
|
||||
| "commissionsOpen"
|
||||
> & {
|
||||
weapons: Pick<TablesInsertable["UserWeapon"], "weaponSplId" | "isFavorite">[];
|
||||
favoriteBadgeIds?: number[] | null;
|
||||
favoriteTrophyIds?: number[] | null;
|
||||
hiddenTrophyIds?: number[] | null;
|
||||
customAvatarImgId?: number | null;
|
||||
@@ -1298,8 +1206,6 @@ type UpdateProfileArgs = Pick<
|
||||
export function updateOwnProfile(args: UpdateProfileArgs) {
|
||||
const userId = actorId();
|
||||
return db.transaction().execute(async (trx) => {
|
||||
await trx.deleteFrom("UserWeapon").where("userId", "=", userId).execute();
|
||||
|
||||
// a removed or replaced custom avatar's image row is cleaned up
|
||||
const current = await trx
|
||||
.selectFrom("User")
|
||||
@@ -1317,40 +1223,20 @@ export function updateOwnProfile(args: UpdateProfileArgs) {
|
||||
.execute();
|
||||
}
|
||||
|
||||
await trx
|
||||
.insertInto("UserWeapon")
|
||||
.values(
|
||||
args.weapons.map((weapon, i) => ({
|
||||
userId,
|
||||
weaponSplId: weapon.weaponSplId,
|
||||
isFavorite: weapon.isFavorite ?? 0,
|
||||
order: i + 1,
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
|
||||
return trx
|
||||
.updateTable("User")
|
||||
.set({
|
||||
country: args.country,
|
||||
bio: args.bio,
|
||||
customUrl: args.customUrl,
|
||||
customName: args.customName,
|
||||
motionSens: args.motionSens,
|
||||
stickSens: args.stickSens,
|
||||
pronouns: args.pronouns,
|
||||
inGameName: args.inGameName,
|
||||
battlefy: args.battlefy,
|
||||
favoriteBadgeIds: args.favoriteBadgeIds
|
||||
? JSON.stringify(args.favoriteBadgeIds)
|
||||
: null,
|
||||
favoriteTrophyIds: args.favoriteTrophyIds
|
||||
? JSON.stringify(args.favoriteTrophyIds)
|
||||
: null,
|
||||
hiddenTrophyIds: args.hiddenTrophyIds
|
||||
? JSON.stringify(args.hiddenTrophyIds)
|
||||
: null,
|
||||
showDiscordUniqueName: args.showDiscordUniqueName,
|
||||
commissionText: args.commissionText,
|
||||
commissionsOpen: args.commissionsOpen,
|
||||
commissionsOpenedAt:
|
||||
@@ -1598,24 +1484,3 @@ export function findIdsByTwitchUsernames(twitchUsernames: string[]) {
|
||||
.where("User.twitch", "in", twitchUsernames)
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** Weapon pool entries with ten-star status. */
|
||||
export function findWeaponPoolByUserId(userId: number) {
|
||||
return db
|
||||
.selectFrom("UserWeaponPool")
|
||||
.leftJoin("TenStarWeapon", (join) =>
|
||||
join
|
||||
.onRef("TenStarWeapon.userId", "=", "UserWeaponPool.userId")
|
||||
.onRef("TenStarWeapon.weaponSplId", "=", "UserWeaponPool.weaponSplId"),
|
||||
)
|
||||
.select([
|
||||
"UserWeaponPool.weaponSplId",
|
||||
"UserWeaponPool.isFavorite",
|
||||
sql<number>`case when "TenStarWeapon"."weaponSplId" is not null then 1 else 0 end`.as(
|
||||
"isTenStar",
|
||||
),
|
||||
])
|
||||
.where("UserWeaponPool.userId", "=", userId)
|
||||
.orderBy("UserWeaponPool.sortOrder", "asc")
|
||||
.execute();
|
||||
}
|
||||
|
||||
@@ -514,6 +514,43 @@ describe("UserRepository", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("UserRepository.findStoredWidgetsByUserId", () => {
|
||||
const sixMainWidgets: Parameters<typeof UserFactory.create>[1] = {
|
||||
widgets: [
|
||||
{ id: "weapon-pool" },
|
||||
{ id: "trophies-owned" },
|
||||
{ id: "badges-owned", settings: { favoriteBadgeIds: [] } },
|
||||
{ id: "badges-authored" },
|
||||
{ id: "badges-managed" },
|
||||
{ id: "builds" },
|
||||
],
|
||||
};
|
||||
|
||||
test("returns all the widgets of a supporter", async () => {
|
||||
const { id } = await UserFactory.create(null, {
|
||||
...sixMainWidgets,
|
||||
patronTier: 2,
|
||||
});
|
||||
|
||||
const widgets = await UserRepository.findStoredWidgetsByUserId(id);
|
||||
|
||||
expect(widgets).toHaveLength(6);
|
||||
});
|
||||
|
||||
test("truncates the widgets over the limit of a non supporter", async () => {
|
||||
const { id } = await UserFactory.create(null, sixMainWidgets);
|
||||
|
||||
const widgets = await UserRepository.findStoredWidgetsByUserId(id);
|
||||
|
||||
expect(widgets.map((widget) => widget.id)).toEqual([
|
||||
"weapon-pool",
|
||||
"trophies-owned",
|
||||
"badges-owned",
|
||||
"badges-authored",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UserRepository.findAllPatronsForFooter", () => {
|
||||
const patrons = UserFactory.pool();
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 { parseFormData } from "~/form/parse.server";
|
||||
import { isSupporter } from "~/modules/permissions/utils";
|
||||
import { userPage } from "~/utils/urls";
|
||||
|
||||
export const action = async ({ request }: { request: Request }) => {
|
||||
@@ -11,7 +12,7 @@ export const action = async ({ request }: { request: Request }) => {
|
||||
|
||||
const result = await parseFormData({
|
||||
request,
|
||||
schema: widgetsEditSchema,
|
||||
schema: widgetsEditSchema(isSupporter(user)),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { type ActionFunction, redirect } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { BADGE } from "~/features/badges/badges-constants";
|
||||
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
|
||||
import { clearTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import { SMALL_TROPHIES_PER_DISPLAY_PAGE } from "~/features/trophies/trophies-constants";
|
||||
@@ -40,21 +39,9 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
? JSON.stringify({ subject: subjectPronoun, object: objectPronoun })
|
||||
: null;
|
||||
|
||||
const [motionSens, stickSens] = data.sensitivity ?? [null, null];
|
||||
|
||||
const weapons = data.weapons.map((w) => ({
|
||||
weaponSplId: w.id,
|
||||
isFavorite: w.isFavorite ? (1 as const) : (0 as const),
|
||||
}));
|
||||
|
||||
const isSupporter = user.roles?.includes("SUPPORTER");
|
||||
const isArtist = user.roles?.includes("ARTIST");
|
||||
|
||||
const maxBadgeCount = isSupporter
|
||||
? BADGE.SMALL_BADGES_PER_DISPLAY_PAGE + 1
|
||||
: 1;
|
||||
const limitedBadgeIds = data.favoriteBadgeIds.slice(0, maxBadgeCount);
|
||||
|
||||
const hiddenTrophySet = new Set(data.hiddenTrophyIds);
|
||||
const limitedTrophyIds = isSupporter
|
||||
? data.favoriteTrophyIds
|
||||
@@ -64,29 +51,18 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
|
||||
const editedUser = await UserRepository.updateOwnProfile({
|
||||
country: data.country,
|
||||
bio: data.bio,
|
||||
customUrl: data.customUrl,
|
||||
customName: data.customName,
|
||||
motionSens: motionSens !== null ? Number(motionSens) : null,
|
||||
stickSens: stickSens !== null ? Number(stickSens) : null,
|
||||
pronouns,
|
||||
inGameName: data.inGameName,
|
||||
battlefy: data.battlefy,
|
||||
weapons,
|
||||
favoriteBadgeIds: limitedBadgeIds.length > 0 ? limitedBadgeIds : null,
|
||||
favoriteTrophyIds: limitedTrophyIds.length > 0 ? limitedTrophyIds : null,
|
||||
hiddenTrophyIds:
|
||||
data.hiddenTrophyIds.length > 0 ? data.hiddenTrophyIds : null,
|
||||
showDiscordUniqueName: data.showDiscordUniqueName ? 1 : 0,
|
||||
commissionsOpen: isArtist && data.commissionsOpen ? 1 : 0,
|
||||
commissionText: isArtist ? data.commissionText : null,
|
||||
customAvatarImgId: isSupporter ? data.customAvatar : null,
|
||||
});
|
||||
|
||||
await UserRepository.updateOwnPreferences({
|
||||
newProfileEnabled: isSupporter ? data.newProfileEnabled : false,
|
||||
});
|
||||
|
||||
// TODO: to transaction
|
||||
if (data.inGameName) {
|
||||
const tournamentIdsAffected =
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import * as UserRepository from "./UserRepository.server";
|
||||
|
||||
describe("profile bio is always a string", () => {
|
||||
test("keeps a JSON-object-shaped bio as text (not a parsed object)", async () => {
|
||||
// a bio the user typed that happens to be valid JSON of object shape
|
||||
const user = await UserFactory.create({
|
||||
profile: { bio: '{"note":"gg"}' },
|
||||
});
|
||||
|
||||
const profile = await UserRepository.findProfileByIdentifier(
|
||||
String(user.id),
|
||||
);
|
||||
|
||||
// the profile page renders bio directly as a React child; an object would 500 the page
|
||||
expect(typeof profile?.bio).toBe("string");
|
||||
});
|
||||
});
|
||||
@@ -147,6 +147,12 @@
|
||||
gap: var(--s-3);
|
||||
}
|
||||
|
||||
.weapon {
|
||||
padding: var(--s-2);
|
||||
border-radius: 100%;
|
||||
background-color: var(--color-bg-high);
|
||||
}
|
||||
|
||||
.weaponGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(40px, 1fr));
|
||||
|
||||
@@ -38,6 +38,7 @@ import type {
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import { logger } from "~/utils/logger";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import { rawSensToString } from "~/utils/strings";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import {
|
||||
brandImageUrl,
|
||||
@@ -73,35 +74,37 @@ export function Widget({
|
||||
const content = () => {
|
||||
switch (widget.id) {
|
||||
case "bio":
|
||||
return <article>{widget.data.bio}</article>;
|
||||
return widget.data.bio ? <article>{widget.data.bio}</article> : null;
|
||||
case "bio-md":
|
||||
return (
|
||||
return widget.data.bio ? (
|
||||
<article>
|
||||
<Markdown>{widget.data.bio}</Markdown>
|
||||
</article>
|
||||
);
|
||||
) : null;
|
||||
case "trophies-owned":
|
||||
return <TrophyDisplay trophies={widget.data} userId={user.id} />;
|
||||
return widget.data.length === 0 ? null : (
|
||||
<TrophyDisplay trophies={widget.data} userId={user.id} />
|
||||
);
|
||||
case "badges-owned":
|
||||
return (
|
||||
return widget.data.length === 0 ? null : (
|
||||
<BadgeDisplay badges={widget.data} key={`badges-owned-${user.id}`} />
|
||||
);
|
||||
case "badges-authored":
|
||||
return (
|
||||
return widget.data.length === 0 ? null : (
|
||||
<BadgeDisplay
|
||||
badges={widget.data}
|
||||
key={`badges-authored-${user.id}`}
|
||||
/>
|
||||
);
|
||||
case "badges-managed":
|
||||
return (
|
||||
return widget.data.length === 0 ? null : (
|
||||
<BadgeDisplay
|
||||
badges={widget.data}
|
||||
key={`badges-managed-${user.id}`}
|
||||
/>
|
||||
);
|
||||
case "teams":
|
||||
return (
|
||||
return widget.data.length === 0 ? null : (
|
||||
<Memberships
|
||||
memberships={widget.data.map((team) => ({
|
||||
id: team.id,
|
||||
@@ -115,7 +118,7 @@ export function Widget({
|
||||
/>
|
||||
);
|
||||
case "organizations":
|
||||
return (
|
||||
return widget.data.length === 0 ? null : (
|
||||
<Memberships
|
||||
memberships={widget.data.map((org) => ({
|
||||
id: org.id,
|
||||
@@ -249,7 +252,10 @@ export function Widget({
|
||||
<WeaponPool weapons={widget.data} />
|
||||
);
|
||||
case "sens":
|
||||
return <SensWidget data={widget.data} />;
|
||||
return typeof widget.data.motionSens !== "number" &&
|
||||
typeof widget.data.stickSens !== "number" ? null : (
|
||||
<SensWidget data={widget.data} />
|
||||
);
|
||||
case "art":
|
||||
return widget.data.length === 0 ? null : (
|
||||
<ArtWidget arts={widget.data} />
|
||||
@@ -303,8 +309,12 @@ export function Widget({
|
||||
}
|
||||
})();
|
||||
|
||||
const renderedContent = content();
|
||||
|
||||
if (!renderedContent) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.widget}>
|
||||
<div className={styles.widget} data-testid={`widget-${widget.id}`}>
|
||||
<div className={styles.header}>
|
||||
<h2 className={styles.headerText}>{t(`user:widget.${widget.id}`)}</h2>
|
||||
{widgetLink ? (
|
||||
@@ -313,7 +323,7 @@ export function Widget({
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.content}>{content()}</div>
|
||||
<div className={styles.content}>{renderedContent}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -639,10 +649,15 @@ function WeaponPool({
|
||||
}) {
|
||||
return (
|
||||
<div className="stack horizontal sm justify-center flex-wrap">
|
||||
{weapons.map((weapon) => {
|
||||
{weapons.map((weapon, i) => {
|
||||
return (
|
||||
<div key={weapon.weaponSplId} className="u__weapon">
|
||||
<WeaponImage weapon={weapon} width={38} height={38} />
|
||||
<div key={weapon.weaponSplId} className={styles.weapon}>
|
||||
<WeaponImage
|
||||
testId={`${weapon.weaponSplId}-${i + 1}`}
|
||||
weapon={weapon}
|
||||
width={38}
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -679,9 +694,6 @@ function SensWidget({
|
||||
}) {
|
||||
const { t } = useTranslation(["user"]);
|
||||
|
||||
const rawSensToString = (sens: number) =>
|
||||
`${sens > 0 ? "+" : ""}${sens / 10}`;
|
||||
|
||||
return (
|
||||
<div className="stack md items-center">
|
||||
<img
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLoaderData } from "react-router";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { BADGE } from "~/features/badges/badges-constants";
|
||||
import { type CustomFieldRenderProps, FormField } from "~/form/FormField";
|
||||
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
|
||||
import { useHasRole } from "~/modules/permissions/hooks";
|
||||
import {
|
||||
getWidgetFormSchema,
|
||||
TIMEZONE_OPTIONS,
|
||||
} from "../core/widgets/widget-form-schemas";
|
||||
import type { loader } from "../loaders/u.$identifier.edit-widgets.server";
|
||||
import { USER } from "../user-page-constants";
|
||||
import { GameBadgeSelectField } from "./GameBadgeSelectField";
|
||||
|
||||
@@ -89,6 +93,8 @@ function WidgetFormFields({ widgetId }: { widgetId: string }) {
|
||||
return <FormField name="links" />;
|
||||
case "tier-list":
|
||||
return <FormField name="searchParams" />;
|
||||
case "badges-owned":
|
||||
return <FavoriteBadgesField />;
|
||||
case "game-badges":
|
||||
return (
|
||||
<FormField name="badgeIds">
|
||||
@@ -116,6 +122,19 @@ function WidgetFormFields({ widgetId }: { widgetId: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
function FavoriteBadgesField() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const isSupporter = useHasRole("SUPPORTER");
|
||||
|
||||
return (
|
||||
<FormField
|
||||
name="favoriteBadgeIds"
|
||||
options={data.ownedBadges}
|
||||
maxCount={isSupporter ? BADGE.SMALL_BADGES_PER_DISPLAY_PAGE + 1 : 1}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const SENS_OPTIONS = [
|
||||
-50, -45, -40, -35, -30, -25, -20, -15, -10, -5, 0, 5, 10, 15, 20, 25, 30, 35,
|
||||
40, 45, 50,
|
||||
|
||||
@@ -10,13 +10,12 @@ const badge = (id: number) => ({
|
||||
describe("sortBadgesByFavorites", () => {
|
||||
test("returns badges sorted by descending id when no favorites", () => {
|
||||
const result = sortBadgesByFavorites({
|
||||
favoriteBadgeIds: null,
|
||||
favoriteBadgeIds: [],
|
||||
badges: [badge(1), badge(3), badge(2)],
|
||||
patronTier: null,
|
||||
});
|
||||
|
||||
expect(result.badges.map((b) => b.id)).toEqual([3, 2, 1]);
|
||||
expect(result.favoriteBadgeIds).toBeNull();
|
||||
expect(result.map((b) => b.id)).toEqual([3, 2, 1]);
|
||||
});
|
||||
|
||||
test("places favorites first in order for supporters", () => {
|
||||
@@ -26,8 +25,7 @@ describe("sortBadgesByFavorites", () => {
|
||||
patronTier: 2,
|
||||
});
|
||||
|
||||
expect(result.badges.map((b) => b.id)).toEqual([2, 1, 3]);
|
||||
expect(result.favoriteBadgeIds).toEqual([2, 1]);
|
||||
expect(result.map((b) => b.id)).toEqual([2, 1, 3]);
|
||||
});
|
||||
|
||||
test("limits non-supporters to one favorite", () => {
|
||||
@@ -37,27 +35,16 @@ describe("sortBadgesByFavorites", () => {
|
||||
patronTier: null,
|
||||
});
|
||||
|
||||
expect(result.favoriteBadgeIds).toEqual([2]);
|
||||
expect(result.badges[0].id).toBe(2);
|
||||
expect(result.map((b) => b.id)).toEqual([2, 3, 1]);
|
||||
});
|
||||
|
||||
test("filters out unowned favorite badge ids", () => {
|
||||
test("ignores favorite badge ids no longer owned", () => {
|
||||
const result = sortBadgesByFavorites({
|
||||
favoriteBadgeIds: [99, 1],
|
||||
badges: [badge(1), badge(2)],
|
||||
patronTier: 2,
|
||||
});
|
||||
|
||||
expect(result.favoriteBadgeIds).toEqual([1]);
|
||||
});
|
||||
|
||||
test("returns null favoriteBadgeIds when all favorites are unowned", () => {
|
||||
const result = sortBadgesByFavorites({
|
||||
favoriteBadgeIds: [99],
|
||||
badges: [badge(1), badge(2)],
|
||||
patronTier: 2,
|
||||
});
|
||||
|
||||
expect(result.favoriteBadgeIds).toBeNull();
|
||||
expect(result.map((b) => b.id)).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,39 +1,32 @@
|
||||
import { isSupporter } from "~/modules/permissions/utils";
|
||||
|
||||
interface SortBadgesByFavoritesArgs<T extends { id: number }[]> {
|
||||
favoriteBadgeIds: number[] | null;
|
||||
favoriteBadgeIds: number[];
|
||||
badges: T;
|
||||
patronTier: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Favorite badges first, in the order the user picked them, the rest by descending id.
|
||||
* Favorites no longer owned are ignored and non-supporters get only one, handling lapsed
|
||||
* supporter status.
|
||||
*/
|
||||
export function sortBadgesByFavorites<T extends { id: number }[]>({
|
||||
favoriteBadgeIds,
|
||||
badges,
|
||||
patronTier,
|
||||
}: SortBadgesByFavoritesArgs<T>): {
|
||||
badges: T;
|
||||
favoriteBadgeIds: number[] | null;
|
||||
} {
|
||||
// filter out favorite badges no longer owner of
|
||||
let filteredFavoriteIds =
|
||||
favoriteBadgeIds?.filter((badgeId) =>
|
||||
badges.some((badge) => badge.id === badgeId),
|
||||
) ?? null;
|
||||
}: SortBadgesByFavoritesArgs<T>): T {
|
||||
const ownedFavoriteIds = favoriteBadgeIds.filter((badgeId) =>
|
||||
badges.some((badge) => badge.id === badgeId),
|
||||
);
|
||||
|
||||
if (filteredFavoriteIds?.length === 0) {
|
||||
filteredFavoriteIds = null;
|
||||
}
|
||||
const effectiveFavoriteIds = isSupporter({ patronTier })
|
||||
? ownedFavoriteIds
|
||||
: ownedFavoriteIds.slice(0, 1);
|
||||
|
||||
filteredFavoriteIds = isSupporter({ patronTier })
|
||||
? filteredFavoriteIds
|
||||
: filteredFavoriteIds
|
||||
? [filteredFavoriteIds[0]]
|
||||
: null;
|
||||
|
||||
// non-supporters can only have one favorite badge, handle losing supporter status
|
||||
const sortedBadges = badges.toSorted((a, b) => {
|
||||
const aIdx = filteredFavoriteIds?.indexOf(a.id) ?? -1;
|
||||
const bIdx = filteredFavoriteIds?.indexOf(b.id) ?? -1;
|
||||
return badges.toSorted((a, b) => {
|
||||
const aIdx = effectiveFavoriteIds.indexOf(a.id);
|
||||
const bIdx = effectiveFavoriteIds.indexOf(b.id);
|
||||
|
||||
if (aIdx !== bIdx) {
|
||||
if (aIdx === -1) return 1;
|
||||
@@ -44,6 +37,4 @@ export function sortBadgesByFavorites<T extends { id: number }[]>({
|
||||
|
||||
return b.id - a.id;
|
||||
}) as T;
|
||||
|
||||
return { badges: sortedBadges, favoriteBadgeIds: filteredFavoriteIds };
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as BuildRepository from "~/features/builds/BuildRepository.server";
|
||||
import * as FriendRepository from "~/features/friends/FriendRepository.server";
|
||||
import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server";
|
||||
import * as LFGRepository from "~/features/lfg/LFGRepository.server";
|
||||
import * as MatchProfileRepository from "~/features/match-profile/MatchProfileRepository.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";
|
||||
@@ -24,8 +25,11 @@ export const WIDGET_LOADERS = {
|
||||
|
||||
return TrophyRepository.findByOwnerUserId(userId);
|
||||
},
|
||||
"badges-owned": async (userId: number) => {
|
||||
return BadgeRepository.findByOwnerUserId(userId);
|
||||
"badges-owned": async (
|
||||
userId: number,
|
||||
settings: ExtractWidgetSettings<"badges-owned">,
|
||||
) => {
|
||||
return BadgeRepository.findByOwnerUserId(userId, settings.favoriteBadgeIds);
|
||||
},
|
||||
"badges-authored": async (userId: number) => {
|
||||
return BadgeRepository.findByAuthorUserId(userId);
|
||||
@@ -287,7 +291,7 @@ export const WIDGET_LOADERS = {
|
||||
return UserRepository.findCommissionsByUserId(userId);
|
||||
},
|
||||
"weapon-pool": async (userId: number) => {
|
||||
return UserRepository.findWeaponPoolByUserId(userId);
|
||||
return MatchProfileRepository.findWeaponPoolByUserId(userId);
|
||||
},
|
||||
"social-links": async (userId: number) => {
|
||||
return UserRepository.findSocialLinksByUserId(userId);
|
||||
|
||||
95
app/features/user-page/core/widgets/portfolio.test.ts
Normal file
95
app/features/user-page/core/widgets/portfolio.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { widgetsAvailableTo } from "./portfolio";
|
||||
import type { StoredWidget } from "./types";
|
||||
|
||||
const MAIN_WIDGETS: StoredWidget[] = [
|
||||
{ id: "weapon-pool" },
|
||||
{ id: "trophies-owned" },
|
||||
{ id: "badges-owned", settings: { favoriteBadgeIds: [] } },
|
||||
{ id: "badges-authored" },
|
||||
{ id: "badges-managed" },
|
||||
{ id: "builds" },
|
||||
{ id: "videos" },
|
||||
];
|
||||
|
||||
const SIDE_WIDGETS: StoredWidget[] = [
|
||||
{ id: "teams" },
|
||||
{ id: "friends" },
|
||||
{ id: "organizations" },
|
||||
{ id: "join-date" },
|
||||
{ id: "peak-sp" },
|
||||
{ id: "peak-xp" },
|
||||
{ id: "commissions" },
|
||||
{ id: "social-links" },
|
||||
];
|
||||
|
||||
const idsOf = (widgets: StoredWidget[]) => widgets.map((widget) => widget.id);
|
||||
|
||||
describe("widgetsAvailableTo", () => {
|
||||
test("keeps widgets that fit in both slots", () => {
|
||||
const widgets = [...MAIN_WIDGETS.slice(0, 4), ...SIDE_WIDGETS.slice(0, 5)];
|
||||
|
||||
expect(widgetsAvailableTo(widgets, false)).toEqual(widgets);
|
||||
});
|
||||
|
||||
test("drops main widgets over the limit keeping the first ones", () => {
|
||||
expect(idsOf(widgetsAvailableTo(MAIN_WIDGETS, false))).toEqual([
|
||||
"weapon-pool",
|
||||
"trophies-owned",
|
||||
"badges-owned",
|
||||
"badges-authored",
|
||||
]);
|
||||
});
|
||||
|
||||
test("drops side widgets over the limit keeping the first ones", () => {
|
||||
expect(idsOf(widgetsAvailableTo(SIDE_WIDGETS, false))).toEqual([
|
||||
"teams",
|
||||
"friends",
|
||||
"organizations",
|
||||
"join-date",
|
||||
"peak-sp",
|
||||
]);
|
||||
});
|
||||
|
||||
test("allows supporters more widgets per slot", () => {
|
||||
expect(widgetsAvailableTo(MAIN_WIDGETS, true)).toHaveLength(6);
|
||||
expect(widgetsAvailableTo(SIDE_WIDGETS, true)).toHaveLength(7);
|
||||
});
|
||||
|
||||
test("drops supporter only widgets from a non supporter", () => {
|
||||
const widgets: StoredWidget[] = [
|
||||
{ id: "join-date" },
|
||||
{ id: "patron-since" },
|
||||
{ id: "links", settings: { links: [] } },
|
||||
{ id: "teams" },
|
||||
];
|
||||
|
||||
expect(idsOf(widgetsAvailableTo(widgets, false))).toEqual([
|
||||
"join-date",
|
||||
"teams",
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps supporter only widgets for a supporter", () => {
|
||||
const widgets: StoredWidget[] = [{ id: "patron-since" }];
|
||||
|
||||
expect(widgetsAvailableTo(widgets, true)).toEqual(widgets);
|
||||
});
|
||||
|
||||
test("dropped supporter only widgets do not take up a slot", () => {
|
||||
const widgets: StoredWidget[] = [
|
||||
{ id: "patron-since" },
|
||||
...SIDE_WIDGETS.slice(0, 5),
|
||||
];
|
||||
|
||||
expect(idsOf(widgetsAvailableTo(widgets, false))).toEqual(
|
||||
idsOf(SIDE_WIDGETS.slice(0, 5)),
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps widgets of an unknown id", () => {
|
||||
const widgets = [{ id: "removed-widget" } as unknown as StoredWidget];
|
||||
|
||||
expect(widgetsAvailableTo(widgets, false)).toEqual(widgets);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
import type * as v from "valibot";
|
||||
import { TIMEZONES } from "~/features/lfg/lfg-constants";
|
||||
import { USER } from "~/features/user-page/user-page-constants";
|
||||
import type { FormObjectSchema } from "~/form/types";
|
||||
import type { StoredWidget } from "./types";
|
||||
import {
|
||||
artSchema,
|
||||
badgesOwnedSchema,
|
||||
bioMdSchema,
|
||||
bioSchema,
|
||||
favoriteStageSchema,
|
||||
@@ -29,11 +31,12 @@ export const ALL_WIDGETS = {
|
||||
defineWidget({
|
||||
id: "bio-md",
|
||||
slot: "main",
|
||||
supporterOnly: true,
|
||||
schema: bioMdSchema,
|
||||
defaultSettings: { bio: "" },
|
||||
}),
|
||||
defineWidget({ id: "organizations", slot: "side" }),
|
||||
defineWidget({ id: "patron-since", slot: "side" }),
|
||||
defineWidget({ id: "patron-since", slot: "side", supporterOnly: true }),
|
||||
defineWidget({ id: "join-date", slot: "side" }),
|
||||
defineWidget({
|
||||
id: "timezone",
|
||||
@@ -44,6 +47,7 @@ export const ALL_WIDGETS = {
|
||||
defineWidget({
|
||||
id: "favorite-stage",
|
||||
slot: "side",
|
||||
supporterOnly: true,
|
||||
schema: favoriteStageSchema,
|
||||
defaultSettings: { stageId: 1 },
|
||||
}),
|
||||
@@ -64,6 +68,7 @@ export const ALL_WIDGETS = {
|
||||
defineWidget({
|
||||
id: "links",
|
||||
slot: "side",
|
||||
supporterOnly: true,
|
||||
schema: linksSchema,
|
||||
defaultSettings: { links: [] },
|
||||
}),
|
||||
@@ -76,7 +81,12 @@ export const ALL_WIDGETS = {
|
||||
],
|
||||
trophies: [defineWidget({ id: "trophies-owned", slot: "main" })],
|
||||
badges: [
|
||||
defineWidget({ id: "badges-owned", slot: "main" }),
|
||||
defineWidget({
|
||||
id: "badges-owned",
|
||||
slot: "main",
|
||||
schema: badgesOwnedSchema,
|
||||
defaultSettings: { favoriteBadgeIds: [] },
|
||||
}),
|
||||
defineWidget({ id: "badges-authored", slot: "main" }),
|
||||
defineWidget({ id: "badges-managed", slot: "main" }),
|
||||
],
|
||||
@@ -92,6 +102,7 @@ export const ALL_WIDGETS = {
|
||||
defineWidget({
|
||||
id: "peak-xp-unverified",
|
||||
slot: "side",
|
||||
supporterOnly: true,
|
||||
schema: peakXpUnverifiedSchema,
|
||||
defaultSettings: { peakXp: 2000, division: "tentatek" },
|
||||
}),
|
||||
@@ -138,6 +149,7 @@ export const ALL_WIDGETS = {
|
||||
defineWidget({
|
||||
id: "game-badges",
|
||||
slot: "main",
|
||||
supporterOnly: true,
|
||||
schema: gameBadgesSchema,
|
||||
defaultSettings: { badgeIds: [] },
|
||||
}),
|
||||
@@ -150,6 +162,23 @@ export const ALL_WIDGETS = {
|
||||
],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Layout of a user who has not saved their own, matching what the profile page
|
||||
* showed before it was widget based.
|
||||
*/
|
||||
export const DEFAULT_WIDGETS: StoredWidget[] = [
|
||||
{ id: "weapon-pool" },
|
||||
{ id: "x-rank-peaks", settings: { division: "both" } },
|
||||
{ id: "badges-owned", settings: { favoriteBadgeIds: [] } },
|
||||
{ id: "bio", settings: { bio: "" } },
|
||||
{ id: "teams" },
|
||||
{
|
||||
id: "sens",
|
||||
settings: { controller: "s2-pro-con", motionSens: null, stickSens: null },
|
||||
},
|
||||
{ id: "join-date" },
|
||||
];
|
||||
|
||||
export function allWidgetsFlat() {
|
||||
return Object.values(ALL_WIDGETS).flat();
|
||||
}
|
||||
@@ -158,6 +187,47 @@ export function findWidgetById(widgetId: string) {
|
||||
return allWidgetsFlat().find((w) => w.id === widgetId);
|
||||
}
|
||||
|
||||
/** How many widgets fit in each slot, supporters get more. */
|
||||
export function maxWidgetsPerSlot(isSupporter: boolean) {
|
||||
return isSupporter
|
||||
? {
|
||||
main: USER.MAX_MAIN_WIDGETS_SUPPORTER,
|
||||
side: USER.MAX_SIDE_WIDGETS_SUPPORTER,
|
||||
}
|
||||
: { main: USER.MAX_MAIN_WIDGETS, side: USER.MAX_SIDE_WIDGETS };
|
||||
}
|
||||
|
||||
/** Drops supporter only widgets and widgets past the slot limits e.g. when supporter status lapsed. */
|
||||
export function widgetsAvailableTo(
|
||||
widgets: StoredWidget[],
|
||||
isSupporter: boolean,
|
||||
): StoredWidget[] {
|
||||
const max = maxWidgetsPerSlot(isSupporter);
|
||||
const result: StoredWidget[] = [];
|
||||
let mainCount = 0;
|
||||
let sideCount = 0;
|
||||
|
||||
for (const widget of widgets) {
|
||||
const definition = findWidgetById(widget.id);
|
||||
|
||||
if (!isSupporter && definition?.supporterOnly) continue;
|
||||
|
||||
const slot = definition?.slot;
|
||||
|
||||
if (slot === "main") {
|
||||
mainCount++;
|
||||
if (mainCount > max.main) continue;
|
||||
} else if (slot === "side") {
|
||||
sideCount++;
|
||||
if (sideCount > max.side) continue;
|
||||
}
|
||||
|
||||
result.push(widget);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function defineWidget<
|
||||
const Id extends string,
|
||||
const Slot extends "main" | "side",
|
||||
@@ -165,6 +235,7 @@ function defineWidget<
|
||||
>(def: {
|
||||
id: Id;
|
||||
slot: Slot;
|
||||
supporterOnly?: true;
|
||||
schema: S;
|
||||
defaultSettings: v.InferOutput<S>;
|
||||
}): typeof def;
|
||||
@@ -172,7 +243,12 @@ function defineWidget<
|
||||
function defineWidget<
|
||||
const Id extends string,
|
||||
const Slot extends "main" | "side",
|
||||
>(def: { id: Id; slot: Slot; schema?: never }): typeof def;
|
||||
>(def: {
|
||||
id: Id;
|
||||
slot: Slot;
|
||||
supporterOnly?: true;
|
||||
schema?: never;
|
||||
}): typeof def;
|
||||
function defineWidget(def: Record<string, unknown>) {
|
||||
return def;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import * as v from "valibot";
|
||||
import { ART_SOURCES } from "~/features/art/art-types";
|
||||
import { BADGE } from "~/features/badges/badges-constants";
|
||||
import { TIMEZONES } from "~/features/lfg/lfg-constants";
|
||||
import {
|
||||
array,
|
||||
badges,
|
||||
customField,
|
||||
numberField,
|
||||
select,
|
||||
@@ -126,6 +128,13 @@ export const tierListSchema = v.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const badgesOwnedSchema = v.object({
|
||||
favoriteBadgeIds: badges({
|
||||
label: "labels.profileFavoriteBadges",
|
||||
maxCount: BADGE.SMALL_BADGES_PER_DISPLAY_PAGE + 1,
|
||||
}),
|
||||
});
|
||||
|
||||
const gameBadgeId = v.pipe(
|
||||
v.string(),
|
||||
v.check((val) => (GAME_BADGE_IDS as readonly string[]).includes(val)),
|
||||
@@ -157,6 +166,7 @@ const WIDGET_FORM_SCHEMAS: Record<string, FormObjectSchema> = {
|
||||
art: artSchema,
|
||||
links: linksSchema,
|
||||
"tier-list": tierListSchema,
|
||||
"badges-owned": badgesOwnedSchema,
|
||||
"game-badges": gameBadgesSchema,
|
||||
"game-badges-small": gameBadgesSmallSchema,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as BadgeRepository from "~/features/badges/BadgeRepository.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
|
||||
export const loader = async () => {
|
||||
@@ -8,5 +9,11 @@ export const loader = async () => {
|
||||
user.id,
|
||||
);
|
||||
|
||||
return { currentWidgets };
|
||||
const badgesOwnedWidget = currentWidgets.find((w) => w.id === "badges-owned");
|
||||
const ownedBadges = await BadgeRepository.findByOwnerUserId(
|
||||
user.id,
|
||||
badgesOwnedWidget?.settings.favoriteBadgeIds ?? [],
|
||||
);
|
||||
|
||||
return { currentWidgets, ownedBadges };
|
||||
};
|
||||
|
||||
@@ -20,9 +20,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
|
||||
const userProfile = (await UserRepository.findProfileByIdentifier(
|
||||
identifier,
|
||||
true,
|
||||
))!;
|
||||
const preferences = await UserRepository.findPreferencesByUserId(user.id);
|
||||
const friendCodeResult = await UserRepository.findCurrentFriendCodeByUserId(
|
||||
user.id,
|
||||
);
|
||||
@@ -32,12 +30,9 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
|
||||
return {
|
||||
user: userProfile,
|
||||
favoriteBadgeIds: userProfile.favoriteBadgeIds,
|
||||
favoriteTrophyIds: userProfile.favoriteTrophyIds,
|
||||
hiddenTrophyIds: userProfile.hiddenTrophyIds,
|
||||
ownedTrophies,
|
||||
discordUniqueName: userProfile.discordUniqueName,
|
||||
newProfileEnabled: preferences?.newProfileEnabled ?? false,
|
||||
friendCode: friendCodeResult?.friendCode ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
|
||||
import { canAccessTrophies } from "~/features/trophies/trophies-utils";
|
||||
import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { notFoundIfNullish } from "~/utils/remix.server";
|
||||
@@ -15,32 +12,8 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
userIds: [userId],
|
||||
});
|
||||
|
||||
const widgetsEnabled = await UserRepository.findEnabledWidgetsByIdentifier(
|
||||
params.identifier!,
|
||||
);
|
||||
|
||||
if (widgetsEnabled) {
|
||||
return {
|
||||
type: "new" as const,
|
||||
widgets: notFoundIfNullish(
|
||||
await UserRepository.findWidgetsByUserId(params.identifier!),
|
||||
),
|
||||
...userCards,
|
||||
};
|
||||
}
|
||||
|
||||
const user = notFoundIfNullish(
|
||||
await UserRepository.findProfileByIdentifier(params.identifier!),
|
||||
);
|
||||
|
||||
const trophies = canAccessTrophies(getUser())
|
||||
? await TrophyRepository.findByOwnerUserId(user.id)
|
||||
: [];
|
||||
|
||||
return {
|
||||
type: "old" as const,
|
||||
user,
|
||||
trophies,
|
||||
widgets: await UserRepository.findWidgetsByUserId(userId),
|
||||
...userCards,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { type LoaderFunctionArgs, redirect } from "react-router";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import * as FriendRepository from "~/features/friends/FriendRepository.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { userPageRedirectPath } from "~/features/user-page/user-page-urls";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import { notFoundIfNullish } from "~/utils/remix.server";
|
||||
|
||||
export type UserPageLoaderData = SerializeFrom<typeof loader>;
|
||||
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
export const loader = async ({ params, url }: LoaderFunctionArgs) => {
|
||||
const loggedInUser = getUser();
|
||||
|
||||
const user = notFoundIfNullish(
|
||||
@@ -17,9 +18,10 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
),
|
||||
);
|
||||
|
||||
const widgetsEnabled = await UserRepository.findEnabledWidgetsByIdentifier(
|
||||
params.identifier!,
|
||||
);
|
||||
const redirectPath = userPageRedirectPath(url, user);
|
||||
if (redirectPath) {
|
||||
throw redirect(redirectPath);
|
||||
}
|
||||
|
||||
const mutualFriends =
|
||||
loggedInUser && loggedInUser.id !== user.id
|
||||
@@ -32,7 +34,6 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
return {
|
||||
user,
|
||||
customTheme: user.customTheme,
|
||||
type: widgetsEnabled ? ("new" as const) : ("old" as const),
|
||||
mutualFriends,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -204,3 +204,16 @@
|
||||
border: var(--border-style);
|
||||
border-radius: var(--radius-box);
|
||||
}
|
||||
|
||||
.supporterMax {
|
||||
margin-left: var(--s-1);
|
||||
color: var(--color-text-accent);
|
||||
font-weight: var(--weight-semi);
|
||||
}
|
||||
|
||||
.supporterOnly {
|
||||
color: var(--color-text-accent);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-semi);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Search as SearchIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFetcher, useLoaderData } from "react-router";
|
||||
import { Link, useFetcher, useLoaderData, useMatches } from "react-router";
|
||||
import * as v from "valibot";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { Input } from "~/components/Input";
|
||||
@@ -30,23 +30,38 @@ import {
|
||||
ALL_WIDGETS,
|
||||
defaultStoredWidget,
|
||||
findWidgetById,
|
||||
maxWidgetsPerSlot,
|
||||
} from "~/features/user-page/core/widgets/portfolio";
|
||||
import { getWidgetFormSchema } from "~/features/user-page/core/widgets/widget-form-schemas";
|
||||
import { USER } from "~/features/user-page/user-page-constants";
|
||||
import { useHydrated } from "~/hooks/useHydrated";
|
||||
import { useHasRole } from "~/modules/permissions/hooks";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { SUPPORT_PAGE, userPage } from "~/utils/urls";
|
||||
import { action } from "../actions/u.$identifier.edit-widgets.server";
|
||||
import { SubPageHeader } from "../components/SubPageHeader";
|
||||
import { WidgetSettingsForm } from "../components/WidgetSettingsForm";
|
||||
import { loader } from "../loaders/u.$identifier.edit-widgets.server";
|
||||
import type { UserPageLoaderData } from "../loaders/u.$identifier.server";
|
||||
import styles from "./u.$identifier.edit-widgets.module.css";
|
||||
|
||||
export { action, loader };
|
||||
|
||||
type MaxWidgets = ReturnType<typeof maxWidgetsPerSlot>;
|
||||
|
||||
export default function EditWidgetsPage() {
|
||||
const { t } = useTranslation(["user", "common"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const isHydrated = useHydrated();
|
||||
const fetcher = useFetcher();
|
||||
|
||||
const [, parentRoute] = useMatches();
|
||||
invariant(parentRoute);
|
||||
const layoutData = parentRoute.loaderData as UserPageLoaderData;
|
||||
|
||||
const isSupporter = useHasRole("SUPPORTER");
|
||||
const maxWidgets = maxWidgetsPerSlot(isSupporter);
|
||||
|
||||
const [selectedWidgets, setSelectedWidgets] = useState<
|
||||
Array<Tables["UserWidget"]["widget"]>
|
||||
>(data.currentWidgets);
|
||||
@@ -98,8 +113,7 @@ export default function EditWidgetsPage() {
|
||||
|
||||
const currentCount =
|
||||
widget.slot === "main" ? mainWidgets.length : sideWidgets.length;
|
||||
const maxCount =
|
||||
widget.slot === "main" ? USER.MAX_MAIN_WIDGETS : USER.MAX_SIDE_WIDGETS;
|
||||
const maxCount = widget.slot === "main" ? maxWidgets.main : maxWidgets.side;
|
||||
|
||||
if (currentCount >= maxCount) return;
|
||||
|
||||
@@ -150,11 +164,23 @@ export default function EditWidgetsPage() {
|
||||
};
|
||||
|
||||
if (!isHydrated) {
|
||||
return <Placeholder />;
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<SubPageHeader
|
||||
user={layoutData.user}
|
||||
backTo={userPage(layoutData.user)}
|
||||
/>
|
||||
<Placeholder />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<SubPageHeader
|
||||
user={layoutData.user}
|
||||
backTo={userPage(layoutData.user)}
|
||||
/>
|
||||
<header className={styles.header}>
|
||||
<h1>{t("user:widgets.editTitle")}</h1>
|
||||
<div className={styles.actions}>
|
||||
@@ -175,6 +201,7 @@ export default function EditWidgetsPage() {
|
||||
<SelectedWidgetsList
|
||||
mainWidgets={mainWidgets}
|
||||
sideWidgets={sideWidgets}
|
||||
maxWidgets={maxWidgets}
|
||||
onRemoveWidget={removeWidget}
|
||||
onSettingsChange={handleSettingsChange}
|
||||
expandedWidgetId={expandedWidgetId}
|
||||
@@ -189,6 +216,8 @@ export default function EditWidgetsPage() {
|
||||
selectedWidgets={selectedWidgets}
|
||||
mainWidgets={mainWidgets}
|
||||
sideWidgets={sideWidgets}
|
||||
maxWidgets={maxWidgets}
|
||||
isSupporter={isSupporter}
|
||||
onAddWidget={addWidget}
|
||||
/>
|
||||
</section>
|
||||
@@ -202,6 +231,8 @@ interface AvailableWidgetsListProps {
|
||||
selectedWidgets: Array<Tables["UserWidget"]["widget"]>;
|
||||
mainWidgets: Array<Tables["UserWidget"]["widget"]>;
|
||||
sideWidgets: Array<Tables["UserWidget"]["widget"]>;
|
||||
maxWidgets: MaxWidgets;
|
||||
isSupporter: boolean;
|
||||
onAddWidget: (widgetId: string) => void;
|
||||
}
|
||||
|
||||
@@ -209,6 +240,8 @@ function AvailableWidgetsList({
|
||||
selectedWidgets,
|
||||
mainWidgets,
|
||||
sideWidgets,
|
||||
maxWidgets,
|
||||
isSupporter,
|
||||
onAddWidget,
|
||||
}: AvailableWidgetsListProps) {
|
||||
const { t } = useTranslation(["user"]);
|
||||
@@ -253,10 +286,9 @@ function AvailableWidgetsList({
|
||||
? mainWidgets.length
|
||||
: sideWidgets.length;
|
||||
const maxCount =
|
||||
widget.slot === "main"
|
||||
? USER.MAX_MAIN_WIDGETS
|
||||
: USER.MAX_SIDE_WIDGETS;
|
||||
widget.slot === "main" ? maxWidgets.main : maxWidgets.side;
|
||||
const isMaxReached = currentCount >= maxCount;
|
||||
const isLocked = Boolean(widget.supporterOnly) && !isSupporter;
|
||||
|
||||
return (
|
||||
<div key={widget.id} className={styles.widgetCard}>
|
||||
@@ -264,15 +296,25 @@ function AvailableWidgetsList({
|
||||
<span className={styles.widgetName}>
|
||||
{t(`user:widget.${widget.id}` as const)}
|
||||
</span>
|
||||
<SendouButton
|
||||
size="miniscule"
|
||||
variant="outlined"
|
||||
onClick={() => onAddWidget(widget.id)}
|
||||
isDisabled={isSelected || isMaxReached}
|
||||
testId={`add-widget-${widget.id}`}
|
||||
>
|
||||
{t("user:widgets.add")}
|
||||
</SendouButton>
|
||||
{isLocked ? (
|
||||
<Link
|
||||
to={SUPPORT_PAGE}
|
||||
className={styles.supporterOnly}
|
||||
data-testid={`supporter-only-${widget.id}`}
|
||||
>
|
||||
{t("user:widgets.supporterOnly")}
|
||||
</Link>
|
||||
) : (
|
||||
<SendouButton
|
||||
size="miniscule"
|
||||
variant="outlined"
|
||||
onClick={() => onAddWidget(widget.id)}
|
||||
isDisabled={isSelected || isMaxReached}
|
||||
testId={`add-widget-${widget.id}`}
|
||||
>
|
||||
{t("user:widgets.add")}
|
||||
</SendouButton>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.widgetFooter}>
|
||||
<div className={styles.widgetSlot}>
|
||||
@@ -309,6 +351,7 @@ function AvailableWidgetsList({
|
||||
interface SelectedWidgetsListProps {
|
||||
mainWidgets: Array<Tables["UserWidget"]["widget"]>;
|
||||
sideWidgets: Array<Tables["UserWidget"]["widget"]>;
|
||||
maxWidgets: MaxWidgets;
|
||||
onRemoveWidget: (widgetId: string) => void;
|
||||
onSettingsChange: (widgetId: string, settings: any) => void;
|
||||
expandedWidgetId: string | null;
|
||||
@@ -318,6 +361,7 @@ interface SelectedWidgetsListProps {
|
||||
function SelectedWidgetsList({
|
||||
mainWidgets,
|
||||
sideWidgets,
|
||||
maxWidgets,
|
||||
onRemoveWidget,
|
||||
onSettingsChange,
|
||||
expandedWidgetId,
|
||||
@@ -332,9 +376,11 @@ function SelectedWidgetsList({
|
||||
<span className="stack horizontal xs">
|
||||
<MainSlotIcon size={24} /> {t("user:widgets.mainSlot")}
|
||||
</span>
|
||||
<span className={styles.slotCount}>
|
||||
{mainWidgets.length}/{USER.MAX_MAIN_WIDGETS}
|
||||
</span>
|
||||
<SlotCount
|
||||
count={mainWidgets.length}
|
||||
max={maxWidgets.main}
|
||||
supporterMax={USER.MAX_MAIN_WIDGETS_SUPPORTER}
|
||||
/>
|
||||
</div>
|
||||
<SortableContext items={mainWidgets.map((w) => w.id)}>
|
||||
<div className={styles.widgetList}>
|
||||
@@ -363,9 +409,11 @@ function SelectedWidgetsList({
|
||||
<span className="stack horizontal xs">
|
||||
<SideSlotIcon size={24} /> {t("user:widgets.sideSlot")}
|
||||
</span>
|
||||
<span className={styles.slotCount}>
|
||||
{sideWidgets.length}/{USER.MAX_SIDE_WIDGETS}
|
||||
</span>
|
||||
<SlotCount
|
||||
count={sideWidgets.length}
|
||||
max={maxWidgets.side}
|
||||
supporterMax={USER.MAX_SIDE_WIDGETS_SUPPORTER}
|
||||
/>
|
||||
</div>
|
||||
<SortableContext items={sideWidgets.map((w) => w.id)}>
|
||||
<div className={styles.widgetList}>
|
||||
@@ -392,6 +440,29 @@ function SelectedWidgetsList({
|
||||
);
|
||||
}
|
||||
|
||||
function SlotCount({
|
||||
count,
|
||||
max,
|
||||
supporterMax,
|
||||
}: {
|
||||
count: number;
|
||||
max: number;
|
||||
supporterMax: number;
|
||||
}) {
|
||||
const { t } = useTranslation(["user"]);
|
||||
|
||||
return (
|
||||
<span className={styles.slotCount}>
|
||||
{count}/{max}
|
||||
{max === supporterMax ? null : (
|
||||
<Link to={SUPPORT_PAGE} className={styles.supporterMax}>
|
||||
{t("user:widgets.supporterMax", { max: supporterMax })}
|
||||
</Link>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface DraggableWidgetItemProps {
|
||||
widget: Tables["UserWidget"]["widget"];
|
||||
onRemove: (widgetId: string) => void;
|
||||
@@ -443,6 +514,7 @@ function DraggableWidgetItem({
|
||||
size="miniscule"
|
||||
variant="outlined"
|
||||
onClick={() => onToggleExpanded(widget.id)}
|
||||
testId={`widget-settings-${widget.id}`}
|
||||
>
|
||||
{isExpanded
|
||||
? t("common:actions.hide")
|
||||
@@ -453,6 +525,7 @@ function DraggableWidgetItem({
|
||||
size="miniscule"
|
||||
variant="minimal-destructive"
|
||||
onClick={() => onRemove(widget.id)}
|
||||
testId={`remove-widget-${widget.id}`}
|
||||
>
|
||||
{t("user:widgets.remove")}
|
||||
</SendouButton>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import { wrappedAction } from "~/utils/Test";
|
||||
import type { userEditProfileBaseSchema } from "../user-page-schemas";
|
||||
import { action as editUserProfileAction } from "./u.$identifier.edit";
|
||||
@@ -11,7 +10,6 @@ const action = wrappedAction<typeof userEditProfileBaseSchema>({
|
||||
});
|
||||
|
||||
const DEFAULT_FIELDS = {
|
||||
battlefy: null,
|
||||
bio: null,
|
||||
commissionsOpen: false,
|
||||
commissionText: null,
|
||||
@@ -19,14 +17,11 @@ const DEFAULT_FIELDS = {
|
||||
customAvatar: null,
|
||||
customName: null,
|
||||
customUrl: null,
|
||||
favoriteBadgeIds: [],
|
||||
favoriteTrophyIds: [],
|
||||
hiddenTrophyIds: [],
|
||||
inGameName: null,
|
||||
sensitivity: [null, null] as [null, null],
|
||||
pronouns: [null, null] as [null, null],
|
||||
weapons: [{ id: 1 as MainWeaponId, isFavorite: false }],
|
||||
showDiscordUniqueName: true,
|
||||
newProfileEnabled: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import { Link, useLoaderData, useMatches } from "react-router";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { FriendCodePopover } from "~/components/FriendCodePopover";
|
||||
import { BADGE } from "~/features/badges/badges-constants";
|
||||
import { SMALL_TROPHIES_PER_DISPLAY_PAGE } from "~/features/trophies/trophies-constants";
|
||||
import { existingImage } from "~/form/image-field";
|
||||
import { SendouForm } from "~/form/SendouForm";
|
||||
@@ -11,8 +10,9 @@ import { useHasRole } from "~/modules/permissions/hooks";
|
||||
import { countryCodeToTranslatedName } from "~/utils/i18n";
|
||||
import invariant from "~/utils/invariant";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { FAQ_PAGE } from "~/utils/urls";
|
||||
import { FAQ_PAGE, userPage } from "~/utils/urls";
|
||||
import { action } from "../actions/u.$identifier.edit.server";
|
||||
import { SubPageHeader } from "../components/SubPageHeader";
|
||||
import { loader } from "../loaders/u.$identifier.edit.server";
|
||||
import type { UserPageLoaderData } from "../loaders/u.$identifier.server";
|
||||
import { COUNTRY_CODES } from "../user-page-constants";
|
||||
@@ -35,13 +35,6 @@ export default function UserEditPage() {
|
||||
|
||||
const countryOptions = useCountryOptions();
|
||||
|
||||
const badgeOptions = data.user.badges.map((badge) => ({
|
||||
id: badge.id,
|
||||
displayName: badge.displayName,
|
||||
code: badge.code,
|
||||
hue: badge.hue,
|
||||
}));
|
||||
|
||||
const trophyOptions = data.ownedTrophies.map((trophy) => ({
|
||||
id: trophy.id,
|
||||
name: trophy.name,
|
||||
@@ -57,84 +50,63 @@ export default function UserEditPage() {
|
||||
customName: data.user.customName ?? "",
|
||||
customUrl: layoutData.user.customUrl ?? "",
|
||||
inGameName: data.user.inGameName ?? "",
|
||||
sensitivity: sensDefaultValue(data.user.motionSens, data.user.stickSens),
|
||||
pronouns: pronounsDefaultValue(data.user.pronouns),
|
||||
battlefy: data.user.battlefy ?? "",
|
||||
country: data.user.country ?? null,
|
||||
favoriteBadgeIds: data.favoriteBadgeIds ?? [],
|
||||
favoriteTrophyIds: data.favoriteTrophyIds ?? [],
|
||||
hiddenTrophyIds: data.hiddenTrophyIds ?? [],
|
||||
weapons: data.user.weapons.map((w) => ({
|
||||
id: w.weaponSplId,
|
||||
isFavorite: Boolean(w.isFavorite),
|
||||
})),
|
||||
bio: data.user.bio ?? "",
|
||||
showDiscordUniqueName: Boolean(data.user.showDiscordUniqueName),
|
||||
commissionsOpen: Boolean(layoutData.user.commissionsOpen),
|
||||
commissionText: layoutData.user.commissionText ?? "",
|
||||
newProfileEnabled: isSupporter && data.newProfileEnabled,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="half-width">
|
||||
<SendouForm
|
||||
schema={userEditProfileBaseSchema}
|
||||
defaultValues={defaultValues}
|
||||
submitButtonText={t("common:actions.save")}
|
||||
revalidateRoot
|
||||
>
|
||||
{({ FormField }) => (
|
||||
<>
|
||||
<FriendCodePopover />
|
||||
<FormField name="customName" />
|
||||
<FormField name="customUrl" />
|
||||
<FormField name="customAvatar" disabled={!isSupporter} />
|
||||
<FormField name="inGameName" />
|
||||
<FormField name="sensitivity" />
|
||||
<FormField name="pronouns" />
|
||||
<FormField name="battlefy" />
|
||||
<FormField name="country" options={countryOptions} />
|
||||
{data.user.badges.length >= 2 ? (
|
||||
<FormField
|
||||
name="favoriteBadgeIds"
|
||||
options={badgeOptions}
|
||||
maxCount={
|
||||
isSupporter ? BADGE.SMALL_BADGES_PER_DISPLAY_PAGE + 1 : 1
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{isSupporter && data.ownedTrophies.length >= 2 ? (
|
||||
<FormField
|
||||
name="favoriteTrophyIds"
|
||||
options={trophyOptions}
|
||||
maxCount={SMALL_TROPHIES_PER_DISPLAY_PAGE}
|
||||
/>
|
||||
) : null}
|
||||
{data.ownedTrophies.length >= 1 ? (
|
||||
<FormField name="hiddenTrophyIds" options={trophyOptions} />
|
||||
) : null}
|
||||
<FormField name="weapons" />
|
||||
<FormField name="bio" />
|
||||
{data.discordUniqueName ? (
|
||||
<FormField name="showDiscordUniqueName" />
|
||||
) : null}
|
||||
{isArtist ? (
|
||||
<>
|
||||
<FormField name="commissionsOpen" />
|
||||
<FormField name="commissionText" />
|
||||
</>
|
||||
) : null}
|
||||
<FormField name="newProfileEnabled" disabled={!isSupporter} />
|
||||
<FormMessage type="info">
|
||||
<Trans i18nKey={"user:discordExplanation"} t={t}>
|
||||
Username, profile picture, YouTube, Bluesky and Twitch accounts
|
||||
come from your Discord account. See
|
||||
<Link to={FAQ_PAGE}>FAQ</Link> for more information.
|
||||
</Trans>
|
||||
</FormMessage>
|
||||
</>
|
||||
)}
|
||||
</SendouForm>
|
||||
<div className="stack lg">
|
||||
<SubPageHeader
|
||||
user={layoutData.user}
|
||||
backTo={userPage(layoutData.user)}
|
||||
/>
|
||||
<div className="half-width">
|
||||
<SendouForm
|
||||
schema={userEditProfileBaseSchema}
|
||||
defaultValues={defaultValues}
|
||||
submitButtonText={t("common:actions.save")}
|
||||
revalidateRoot
|
||||
>
|
||||
{({ FormField }) => (
|
||||
<>
|
||||
<FriendCodePopover />
|
||||
<FormField name="customName" />
|
||||
<FormField name="customUrl" />
|
||||
<FormField name="customAvatar" disabled={!isSupporter} />
|
||||
<FormField name="inGameName" />
|
||||
<FormField name="pronouns" />
|
||||
<FormField name="country" options={countryOptions} />
|
||||
{isSupporter && data.ownedTrophies.length >= 2 ? (
|
||||
<FormField
|
||||
name="favoriteTrophyIds"
|
||||
options={trophyOptions}
|
||||
maxCount={SMALL_TROPHIES_PER_DISPLAY_PAGE}
|
||||
/>
|
||||
) : null}
|
||||
{data.ownedTrophies.length >= 1 ? (
|
||||
<FormField name="hiddenTrophyIds" options={trophyOptions} />
|
||||
) : null}
|
||||
{isArtist ? (
|
||||
<>
|
||||
<FormField name="commissionsOpen" />
|
||||
<FormField name="commissionText" />
|
||||
</>
|
||||
) : null}
|
||||
<FormMessage type="info">
|
||||
<Trans i18nKey={"user:discordExplanation"} t={t}>
|
||||
Username, profile picture, YouTube, Bluesky and Twitch
|
||||
accounts come from your Discord account. See
|
||||
<Link to={FAQ_PAGE}>FAQ</Link> for more information.
|
||||
</Trans>
|
||||
</FormMessage>
|
||||
</>
|
||||
)}
|
||||
</SendouForm>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -162,14 +134,3 @@ function pronounsDefaultValue(
|
||||
if (!pronouns) return [null, null];
|
||||
return [pronouns.subject, pronouns.object];
|
||||
}
|
||||
|
||||
function sensDefaultValue(
|
||||
motionSens: number | null,
|
||||
stickSens: number | null,
|
||||
): [string | null, string | null] {
|
||||
if (motionSens === null && stickSens === null) return [null, null];
|
||||
return [
|
||||
motionSens !== null ? String(motionSens) : null,
|
||||
stickSens !== null ? String(stickSens) : null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -56,7 +56,14 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sideCarousel {
|
||||
/** Side widgets scroll horizontally above the main ones until there is room for two columns. */
|
||||
.widgets {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-6);
|
||||
}
|
||||
|
||||
.side {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
gap: var(--s-4);
|
||||
@@ -69,16 +76,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.mainStack {
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-6);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@container (width >= 720px) {
|
||||
.header {
|
||||
flex-direction: row;
|
||||
@@ -101,19 +104,11 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mainStack {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sideCarousel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.editButtons {
|
||||
margin: initial;
|
||||
}
|
||||
|
||||
.grid {
|
||||
.widgets {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
||||
gap: var(--s-8);
|
||||
@@ -121,181 +116,20 @@
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-6);
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
|
||||
.side {
|
||||
grid-area: 1 / 2;
|
||||
position: sticky;
|
||||
top: 50px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-x: visible;
|
||||
gap: var(--s-6);
|
||||
align-self: start;
|
||||
}
|
||||
}
|
||||
|
||||
.oldPageContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-6);
|
||||
}
|
||||
|
||||
.avatarContainer {
|
||||
display: grid;
|
||||
justify-content: center;
|
||||
column-gap: var(--s-3);
|
||||
grid-template-areas: "avatar name" "avatar team" "avatar ." "avatar ." "socials .";
|
||||
}
|
||||
|
||||
.avatar {
|
||||
min-width: 125px;
|
||||
grid-area: avatar;
|
||||
}
|
||||
|
||||
.team {
|
||||
display: flex;
|
||||
font-weight: var(--weight-bold);
|
||||
color: var(--color-text);
|
||||
gap: var(--s-1-5);
|
||||
grid-area: team;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.name {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
grid-area: name;
|
||||
overflow-wrap: anywhere;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
|
||||
.socials {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--s-1-5);
|
||||
grid-area: socials;
|
||||
padding-block-start: var(--s-3);
|
||||
}
|
||||
|
||||
.socialLink {
|
||||
padding: var(--s-1);
|
||||
border: 1px solid;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.extraInfoHeading {
|
||||
& > svg {
|
||||
width: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.socialLink {
|
||||
& > svg {
|
||||
width: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.socialLinkYoutube {
|
||||
border-color: #f00;
|
||||
background-color: #ff00002f;
|
||||
|
||||
& > svg {
|
||||
fill: #f00;
|
||||
}
|
||||
}
|
||||
|
||||
.socialLinkTwitch {
|
||||
border-color: #9146ff;
|
||||
background-color: #9146ff2f;
|
||||
|
||||
& > svg {
|
||||
fill: #9146ff;
|
||||
}
|
||||
}
|
||||
|
||||
.socialLinkBattlefy {
|
||||
border-color: #de4c5e;
|
||||
background-color: #de4c5e2f;
|
||||
|
||||
& > svg {
|
||||
fill: #de4c5e;
|
||||
}
|
||||
}
|
||||
|
||||
.socialLinkBsky {
|
||||
border-color: #1285fe;
|
||||
background-color: #1285fe2f;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
|
||||
& path {
|
||||
fill: #1285fe;
|
||||
}
|
||||
}
|
||||
|
||||
.extraInfos {
|
||||
display: flex;
|
||||
max-width: 24rem;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-3);
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.extraInfo {
|
||||
padding: var(--s-1) var(--s-1-5);
|
||||
border-radius: var(--radius-box);
|
||||
background-color: var(--color-bg-high);
|
||||
font-size: var(--font-2xs);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.extraInfoHeading {
|
||||
color: var(--color-text-accent);
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
.weapon {
|
||||
padding: var(--s-2);
|
||||
border-radius: 100%;
|
||||
background-color: var(--color-bg-high);
|
||||
}
|
||||
|
||||
.placements {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: var(--s-4);
|
||||
border-radius: var(--radius-box);
|
||||
margin: 0 auto;
|
||||
background-color: var(--color-bg-high);
|
||||
color: var(--color-text);
|
||||
gap: var(--s-6);
|
||||
transition: 0.1s ease-in-out background-color;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-bg-higher);
|
||||
}
|
||||
}
|
||||
|
||||
.placementsMode {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-semi);
|
||||
gap: var(--s-1-5);
|
||||
}
|
||||
|
||||
@container (width >= 480px) {
|
||||
.placements {
|
||||
gap: var(--s-10);
|
||||
}
|
||||
|
||||
.placementsMode {
|
||||
font-size: var(--font-sm);
|
||||
|
||||
& > * {
|
||||
flex: initial;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,33 +3,18 @@ import { Pencil as EditIcon, Puzzle as PuzzleIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
href,
|
||||
Link,
|
||||
useLoaderData,
|
||||
useMatches,
|
||||
useOutletContext,
|
||||
} from "react-router";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { LinkButton, SendouButton } from "~/components/elements/Button";
|
||||
import { SendouPopover } from "~/components/elements/Popover";
|
||||
import { LinkButton } from "~/components/elements/Button";
|
||||
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 { 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 { topSearchPlayerPage } from "~/features/top-search/top-search-urls";
|
||||
import { TrophyDisplay } from "~/features/trophies/components/TrophyDisplay";
|
||||
import { UserCard } from "~/features/user-card/components/UserCard";
|
||||
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";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { bskyUrl, modeImageUrl, navIconUrl, teamPage } from "~/utils/urls";
|
||||
import { MutualFriends } from "../components/MutualFriends";
|
||||
import type { UserPageNavItem } from "../components/UserPageIconNav";
|
||||
import { UserPageIconNav } from "../components/UserPageIconNav";
|
||||
@@ -57,15 +42,6 @@ export const handle: SendouRouteHandle = {
|
||||
};
|
||||
|
||||
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();
|
||||
@@ -74,10 +50,6 @@ function NewUserInfoPage() {
|
||||
const layoutData = parentRoute.loaderData 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");
|
||||
|
||||
@@ -143,368 +115,22 @@ function NewUserInfoPage() {
|
||||
<UserPageIconNav items={navItems} />
|
||||
</div>
|
||||
|
||||
<div className={clsx(styles.sideCarousel, "scrollbar")}>
|
||||
{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.widgets}>
|
||||
<div className={clsx(styles.side, "scrollbar")}>
|
||||
{sideWidgets.map((widget) => (
|
||||
<Widget key={widget.id} widget={widget} user={layoutData.user} />
|
||||
))}
|
||||
</div>
|
||||
<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.loaderData as UserPageLoaderData;
|
||||
|
||||
if (data.type !== "old") {
|
||||
throw new Error("Expected old user data");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.oldPageContainer}>
|
||||
<div className="stack sm">
|
||||
<div className={styles.avatarContainer}>
|
||||
<UserCard userId={layoutData.user.id}>
|
||||
<Avatar
|
||||
user={layoutData.user}
|
||||
size="lg"
|
||||
className={styles.avatar}
|
||||
loading="eager"
|
||||
/>
|
||||
</UserCard>
|
||||
<div>
|
||||
<h2 className={styles.name}>
|
||||
<UserCard userId={layoutData.user.id}>
|
||||
<div>{layoutData.user.username}</div>
|
||||
</UserCard>
|
||||
<div>
|
||||
{data.user.country ? (
|
||||
<Flag countryCode={data.user.country} tiny />
|
||||
) : null}
|
||||
</div>
|
||||
</h2>
|
||||
<TeamInfo />
|
||||
</div>
|
||||
<div className={styles.socials}>
|
||||
{data.user.twitch ? (
|
||||
<SocialLink type="twitch" identifier={data.user.twitch} />
|
||||
) : null}
|
||||
{data.user.youtubeId ? (
|
||||
<SocialLink type="youtube" identifier={data.user.youtubeId} />
|
||||
) : null}
|
||||
{data.user.battlefy ? (
|
||||
<SocialLink type="battlefy" identifier={data.user.battlefy} />
|
||||
) : null}
|
||||
{data.user.bsky ? (
|
||||
<SocialLink type="bsky" identifier={data.user.bsky} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="stack items-center">
|
||||
<MutualFriends mutualFriends={layoutData.mutualFriends} />
|
||||
</div>
|
||||
</div>
|
||||
<ExtraInfos />
|
||||
<WeaponPool />
|
||||
<TopPlacements />
|
||||
{data.trophies.length > 0 ? (
|
||||
<TrophyDisplay
|
||||
trophies={data.trophies}
|
||||
userId={layoutData.user.id}
|
||||
key={`trophies-${layoutData.user.id}`}
|
||||
/>
|
||||
) : null}
|
||||
<BadgeDisplay
|
||||
badges={data.user.badges}
|
||||
key={`badges-${layoutData.user.id}`}
|
||||
/>
|
||||
{data.user.bio && <article>{data.user.bio}</article>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="stack horizontal sm">
|
||||
<Link
|
||||
to={teamPage(data.user.team.customUrl)}
|
||||
className={styles.team}
|
||||
data-testid="main-team-link"
|
||||
>
|
||||
{data.user.team.avatarUrl ? (
|
||||
<img
|
||||
alt=""
|
||||
src={data.user.team.avatarUrl}
|
||||
width={32}
|
||||
height={32}
|
||||
className="rounded-full"
|
||||
/>
|
||||
) : null}
|
||||
<div>
|
||||
{data.user.team.name}
|
||||
{data.user.team.userTeamCustomRole ? (
|
||||
<div className="text-xxs text-lighter font-bold">
|
||||
{data.user.team.userTeamCustomRole}
|
||||
</div>
|
||||
) : data.user.team.userTeamRole ? (
|
||||
<div className="text-xxs text-lighter font-bold">
|
||||
{t(`team:roles.${data.user.team.userTeamRole}`)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
<SecondaryTeamsPopover />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SecondaryTeamsPopover() {
|
||||
const { t } = useTranslation(["team"]);
|
||||
|
||||
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 (
|
||||
<SendouPopover
|
||||
trigger={
|
||||
<SendouButton
|
||||
className="focus-text-decoration self-start"
|
||||
variant="minimal"
|
||||
size="small"
|
||||
>
|
||||
<span
|
||||
className="text-sm font-bold text-main-forced"
|
||||
data-testid="secondary-team-trigger"
|
||||
>
|
||||
+{data.user.secondaryTeams.length}
|
||||
</span>
|
||||
</SendouButton>
|
||||
}
|
||||
>
|
||||
<div className="stack sm">
|
||||
{data.user.secondaryTeams.map((team) => (
|
||||
<div
|
||||
key={team.customUrl}
|
||||
className="stack horizontal md items-center"
|
||||
>
|
||||
<Link
|
||||
to={teamPage(team.customUrl)}
|
||||
className={clsx(styles.team, "text-main-forced")}
|
||||
>
|
||||
{team.avatarUrl ? (
|
||||
<img
|
||||
alt=""
|
||||
src={team.avatarUrl}
|
||||
width={24}
|
||||
height={24}
|
||||
className="rounded-full"
|
||||
/>
|
||||
) : null}
|
||||
{team.name}
|
||||
</Link>
|
||||
{team.userTeamCustomRole ? (
|
||||
<div className="text-xxs text-lighter font-bold">
|
||||
{team.userTeamCustomRole}
|
||||
</div>
|
||||
) : team.userTeamRole ? (
|
||||
<div className="text-xxs text-lighter font-bold">
|
||||
{t(`team:roles.${team.userTeamRole}`)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SendouPopover>
|
||||
);
|
||||
}
|
||||
|
||||
interface SocialLinkProps {
|
||||
type: "youtube" | "twitch" | "battlefy" | "bsky";
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
export function SocialLink({
|
||||
type,
|
||||
identifier,
|
||||
}: {
|
||||
type: SocialLinkProps["type"];
|
||||
identifier: string;
|
||||
}) {
|
||||
const href = () => {
|
||||
switch (type) {
|
||||
case "twitch":
|
||||
return `https://www.twitch.tv/${identifier}`;
|
||||
case "youtube":
|
||||
return `https://www.youtube.com/channel/${identifier}`;
|
||||
case "battlefy":
|
||||
return `https://battlefy.com/users/${identifier}`;
|
||||
case "bsky":
|
||||
return bskyUrl(identifier);
|
||||
default:
|
||||
assertUnreachable(type);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<a
|
||||
className={clsx(styles.socialLink, {
|
||||
[styles.socialLinkYoutube]: type === "youtube",
|
||||
[styles.socialLinkTwitch]: type === "twitch",
|
||||
[styles.socialLinkBattlefy]: type === "battlefy",
|
||||
[styles.socialLinkBsky]: type === "bsky",
|
||||
})}
|
||||
href={href()}
|
||||
>
|
||||
<SocialLinkIcon type={type} />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function SocialLinkIcon({ type }: Pick<SocialLinkProps, "type">) {
|
||||
switch (type) {
|
||||
case "twitch":
|
||||
return <TwitchIcon />;
|
||||
case "youtube":
|
||||
return <YouTubeIcon />;
|
||||
case "battlefy":
|
||||
return <BattlefyIcon />;
|
||||
case "bsky":
|
||||
return <BskyIcon />;
|
||||
default:
|
||||
assertUnreachable(type);
|
||||
}
|
||||
}
|
||||
|
||||
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)}`
|
||||
: null;
|
||||
|
||||
const stickSensText =
|
||||
typeof data.user.stickSens === "number"
|
||||
? `${t("user:stick")} ${rawSensToString(data.user.stickSens)}`
|
||||
: null;
|
||||
|
||||
if (
|
||||
!data.user.inGameName &&
|
||||
typeof data.user.stickSens !== "number" &&
|
||||
!data.user.discordUniqueName &&
|
||||
!data.user.plusTier
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.extraInfos}>
|
||||
<div className={styles.extraInfo}>#{data.user.id}</div>
|
||||
{data.user.discordUniqueName && (
|
||||
<div className={styles.extraInfo}>
|
||||
<span className={styles.extraInfoHeading}>
|
||||
<DiscordIcon />
|
||||
</span>{" "}
|
||||
{data.user.discordUniqueName}
|
||||
</div>
|
||||
)}
|
||||
{data.user.pronouns && (
|
||||
<div className={styles.extraInfo}>
|
||||
<span className={styles.extraInfoHeading}>
|
||||
{t("user:usesPronouns")}
|
||||
</span>{" "}
|
||||
{data.user.pronouns.subject}/{data.user.pronouns.object}
|
||||
</div>
|
||||
)}
|
||||
{data.user.inGameName && (
|
||||
<div className={styles.extraInfo}>
|
||||
<span className={styles.extraInfoHeading}>{t("user:ign.short")}</span>{" "}
|
||||
{data.user.inGameName}
|
||||
</div>
|
||||
)}
|
||||
{typeof data.user.stickSens === "number" && (
|
||||
<div className={styles.extraInfo}>
|
||||
<span className={styles.extraInfoHeading}>{t("user:sens")}</span>{" "}
|
||||
{[motionSensText, stickSensText].filter(Boolean).join(" / ")}
|
||||
</div>
|
||||
)}
|
||||
{data.user.plusTier && (
|
||||
<div className={styles.extraInfo}>
|
||||
<Image path={navIconUrl("plus")} width={20} height={20} alt="" />{" "}
|
||||
{data.user.plusTier}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="stack horizontal sm justify-center">
|
||||
{data.user.weapons.map((weapon, i) => {
|
||||
return (
|
||||
<div key={weapon.weaponSplId} className={styles.weapon}>
|
||||
<WeaponImage
|
||||
testId={`${weapon.weaponSplId}-${i + 1}`}
|
||||
weapon={weapon}
|
||||
width={38}
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileSubtitle({
|
||||
inGameName,
|
||||
pronouns,
|
||||
@@ -554,38 +180,3 @@ function ProfileSubtitle({
|
||||
</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 (
|
||||
<Link
|
||||
to={topSearchPlayerPage(data.user.topPlacements[0].playerId)}
|
||||
className={styles.placements}
|
||||
data-testid="placements-box"
|
||||
>
|
||||
{modesShort.map((mode) => {
|
||||
const placement = data.user.topPlacements.find(
|
||||
(placement) => placement.mode === mode,
|
||||
);
|
||||
|
||||
if (!placement) return null;
|
||||
|
||||
return (
|
||||
<div key={mode} className={styles.placementsMode}>
|
||||
<Image path={modeImageUrl(mode)} alt="" width={24} height={24} />
|
||||
<div>
|
||||
{placement.rank} / {placement.power}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { MetaFunction } from "react-router";
|
||||
import { Outlet, useLoaderData, useLocation, useMatches } from "react-router";
|
||||
import { Outlet, useLoaderData, useLocation } from "react-router";
|
||||
import { Main } from "~/components/Main";
|
||||
import { SubNav, SubNavLink } from "~/components/SubNav";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { useHasRole } from "~/modules/permissions/hooks";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
@@ -11,7 +10,6 @@ import {
|
||||
discordAvatarUrl,
|
||||
userAdminPage,
|
||||
userBuildsPage,
|
||||
userEditProfilePage,
|
||||
userPage,
|
||||
userResultsPage,
|
||||
userVodsPage,
|
||||
@@ -79,17 +77,12 @@ 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.loaderData as any)?.type === "new",
|
||||
);
|
||||
|
||||
const navItems: UserPageNavItem[] = [
|
||||
{
|
||||
to: userSeasonsPage({ user: data.user }),
|
||||
@@ -143,70 +136,6 @@ export default function UserPageLayout() {
|
||||
|
||||
return (
|
||||
<Main bigger={location.pathname.includes("results")}>
|
||||
{isNewUserPage ? null : (
|
||||
<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 ? (
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -6,12 +6,12 @@ export const USER = {
|
||||
BIO_MD_MAX_LENGTH: 8000,
|
||||
CUSTOM_URL_MAX_LENGTH: 32,
|
||||
CUSTOM_NAME_MAX_LENGTH: 32,
|
||||
BATTLEFY_MAX_LENGTH: 32,
|
||||
WEAPON_POOL_MAX_SIZE: 5,
|
||||
COMMISSION_TEXT_MAX_LENGTH: 1000,
|
||||
MOD_NOTE_MAX_LENGTH: 2000,
|
||||
MAX_MAIN_WIDGETS: 5,
|
||||
MAX_SIDE_WIDGETS: 7,
|
||||
MAX_MAIN_WIDGETS: 4,
|
||||
MAX_SIDE_WIDGETS: 5,
|
||||
MAX_MAIN_WIDGETS_SUPPORTER: 6,
|
||||
MAX_SIDE_WIDGETS_SUPPORTER: 7,
|
||||
GAME_BADGES_MAX: 8,
|
||||
GAME_BADGES_SMALL_MAX: 4,
|
||||
};
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import * as v from "valibot";
|
||||
import { BADGE } from "~/features/badges/badges-constants";
|
||||
import { SMALL_TROPHIES_PER_DISPLAY_PAGE } from "~/features/trophies/trophies-constants";
|
||||
import {
|
||||
OBJECT_PRONOUNS,
|
||||
SUBJECT_PRONOUNS,
|
||||
} from "~/features/user-page/user-page-constants";
|
||||
import {
|
||||
badges,
|
||||
checkboxGroup,
|
||||
customField,
|
||||
dualSelectOptional,
|
||||
@@ -43,9 +41,12 @@ import {
|
||||
stackableAbility,
|
||||
superRefine,
|
||||
} from "~/utils/schema";
|
||||
import { rawSensToString } from "~/utils/strings";
|
||||
import { isCustomUrl } from "~/utils/urls";
|
||||
import { allWidgetsFlat, findWidgetById } from "./core/widgets/portfolio";
|
||||
import {
|
||||
allWidgetsFlat,
|
||||
findWidgetById,
|
||||
maxWidgetsPerSlot,
|
||||
} from "./core/widgets/portfolio";
|
||||
import {
|
||||
BUILD_SORT_IDENTIFIERS,
|
||||
HIGHLIGHT_CHECKBOX_NAME,
|
||||
@@ -55,14 +56,6 @@ import {
|
||||
|
||||
export const userParamsSchema = v.object({ identifier: v.string() });
|
||||
|
||||
const SENS_ITEMS = [
|
||||
-50, -45, -40, -35, -30, -25, -20, -15, -10, -5, 0, 5, 10, 15, 20, 25, 30, 35,
|
||||
40, 45, 50,
|
||||
].map((val) => ({
|
||||
label: () => rawSensToString(val),
|
||||
value: String(val),
|
||||
}));
|
||||
|
||||
export const userEditProfileBaseSchema = v.object({
|
||||
customAvatar: image({
|
||||
label: "labels.profileCustomAvatar",
|
||||
@@ -93,19 +86,6 @@ export const userEditProfileBaseSchema = v.object({
|
||||
label: "labels.inGameName",
|
||||
bottomText: "bottomTexts.profileInGameName",
|
||||
}),
|
||||
sensitivity: dualSelectOptional({
|
||||
fields: [
|
||||
{ label: "labels.profileMotionSens", items: SENS_ITEMS },
|
||||
{ label: "labels.profileStickSens", items: SENS_ITEMS },
|
||||
],
|
||||
validate: {
|
||||
func: ([motion, stick]) => {
|
||||
if (motion !== null && stick === null) return false;
|
||||
return true;
|
||||
},
|
||||
message: "errors.profileSensBothOrNeither",
|
||||
},
|
||||
}),
|
||||
pronouns: dualSelectOptional({
|
||||
bottomText: "bottomTexts.profilePronouns",
|
||||
fields: [
|
||||
@@ -127,20 +107,10 @@ export const userEditProfileBaseSchema = v.object({
|
||||
message: "errors.profilePronounsBothOrNeither",
|
||||
},
|
||||
}),
|
||||
battlefy: textFieldOptional({
|
||||
label: "labels.profileBattlefy",
|
||||
bottomText: "bottomTexts.profileBattlefy",
|
||||
leftAddon: "https://battlefy.com/users/",
|
||||
maxLength: USER.BATTLEFY_MAX_LENGTH,
|
||||
}),
|
||||
country: selectDynamicOptional({
|
||||
label: "labels.profileCountry",
|
||||
searchable: true,
|
||||
}),
|
||||
favoriteBadgeIds: badges({
|
||||
label: "labels.profileFavoriteBadges",
|
||||
maxCount: BADGE.SMALL_BADGES_PER_DISPLAY_PAGE + 1,
|
||||
}),
|
||||
favoriteTrophyIds: trophies({
|
||||
label: "labels.profileFavoriteTrophies",
|
||||
maxCount: SMALL_TROPHIES_PER_DISPLAY_PAGE,
|
||||
@@ -148,18 +118,6 @@ export const userEditProfileBaseSchema = v.object({
|
||||
hiddenTrophyIds: trophies({
|
||||
label: "labels.profileHiddenTrophies",
|
||||
}),
|
||||
weapons: weaponPool({
|
||||
label: "labels.weaponPool",
|
||||
maxCount: USER.WEAPON_POOL_MAX_SIZE,
|
||||
}),
|
||||
bio: textAreaOptional({
|
||||
label: "labels.bio",
|
||||
maxLength: USER.BIO_MAX_LENGTH,
|
||||
}),
|
||||
showDiscordUniqueName: toggle({
|
||||
label: "labels.profileShowDiscordUniqueName",
|
||||
bottomText: "bottomTexts.profileShowDiscordUniqueName",
|
||||
}),
|
||||
commissionsOpen: toggle({
|
||||
label: "labels.profileCommissionsOpen",
|
||||
bottomText: "bottomTexts.profileCommissionsOpen",
|
||||
@@ -169,10 +127,6 @@ export const userEditProfileBaseSchema = v.object({
|
||||
bottomText: "bottomTexts.profileCommissionText",
|
||||
maxLength: USER.COMMISSION_TEXT_MAX_LENGTH,
|
||||
}),
|
||||
newProfileEnabled: toggle({
|
||||
label: "labels.profileNewProfileEnabled",
|
||||
bottomText: "bottomTexts.profileNewProfileEnabled",
|
||||
}),
|
||||
});
|
||||
|
||||
export const editHighlightsActionSchema = v.object({
|
||||
@@ -217,29 +171,31 @@ const widgetSettingsSchemas = allWidgetsFlat().map((widget) => {
|
||||
|
||||
const widgetSettingsSchema = v.union(widgetSettingsSchemas);
|
||||
|
||||
export const widgetsEditSchema = v.object({
|
||||
widgets: preprocess(
|
||||
safeJSONParse,
|
||||
v.pipe(
|
||||
v.array(widgetSettingsSchema),
|
||||
v.maxLength(USER.MAX_MAIN_WIDGETS + USER.MAX_SIDE_WIDGETS),
|
||||
v.check((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
|
||||
);
|
||||
}),
|
||||
export const widgetsEditSchema = (isSupporter: boolean) => {
|
||||
const max = maxWidgetsPerSlot(isSupporter);
|
||||
|
||||
return v.object({
|
||||
widgets: preprocess(
|
||||
safeJSONParse,
|
||||
v.pipe(
|
||||
v.array(widgetSettingsSchema),
|
||||
v.maxLength(max.main + max.side),
|
||||
v.check((widgets) => {
|
||||
let mainCount = 0;
|
||||
let sideCount = 0;
|
||||
for (const w of widgets) {
|
||||
const def = findWidgetById(w.id);
|
||||
if (!def) return false;
|
||||
if (def.supporterOnly && !isSupporter) return false;
|
||||
if (def.slot === "main") mainCount++;
|
||||
else sideCount++;
|
||||
}
|
||||
return mainCount <= max.main && sideCount <= max.side;
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const headGearIdSchema = v.pipe(
|
||||
v.nullable(v.number()),
|
||||
|
||||
50
app/features/user-page/user-page-urls.test.ts
Normal file
50
app/features/user-page/user-page-urls.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { userPageRedirectPath } from "./user-page-urls";
|
||||
|
||||
const WITH_CUSTOM_URL = { customUrl: "sendou", discordId: "79237403620945920" };
|
||||
const WITHOUT_CUSTOM_URL = { customUrl: null, discordId: "79237403620945920" };
|
||||
|
||||
describe("userPageRedirectPath", () => {
|
||||
test.each([
|
||||
{
|
||||
why: "id -> custom url",
|
||||
url: "/u/274",
|
||||
user: WITH_CUSTOM_URL,
|
||||
expected: "/u/sendou",
|
||||
},
|
||||
{
|
||||
why: "id -> discord id when no custom url",
|
||||
url: "/u/274",
|
||||
user: WITHOUT_CUSTOM_URL,
|
||||
expected: "/u/79237403620945920",
|
||||
},
|
||||
{
|
||||
why: "discord id -> custom url",
|
||||
url: "/u/79237403620945920",
|
||||
user: WITH_CUSTOM_URL,
|
||||
expected: "/u/sendou",
|
||||
},
|
||||
{
|
||||
why: "custom url stays",
|
||||
url: "/u/sendou",
|
||||
user: WITH_CUSTOM_URL,
|
||||
expected: null,
|
||||
},
|
||||
{
|
||||
why: "discord id stays when no custom url",
|
||||
url: "/u/79237403620945920",
|
||||
user: WITHOUT_CUSTOM_URL,
|
||||
expected: null,
|
||||
},
|
||||
{
|
||||
why: "keeps the sub page and search params",
|
||||
url: "/u/274/seasons/stats?season=1",
|
||||
user: WITH_CUSTOM_URL,
|
||||
expected: "/u/sendou/seasons/stats?season=1",
|
||||
},
|
||||
])("$why", ({ url, user, expected }) => {
|
||||
expect(userPageRedirectPath(new URL(url, "https://sendou.ink"), user)).toBe(
|
||||
expected,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -56,3 +56,18 @@ export const userNewBuildPage = (
|
||||
build: params.build,
|
||||
})
|
||||
: `${userBuildsPage(user)}/new`;
|
||||
|
||||
/**
|
||||
* Path the given user page URL should redirect to, or `null` if it already uses the user's
|
||||
* preferred identifier (custom URL, falling back to their Discord id).
|
||||
*/
|
||||
export function userPageRedirectPath(url: URL, user: UserLinkArgs) {
|
||||
const segments = url.pathname.split("/");
|
||||
const preferredIdentifier = user.customUrl ?? user.discordId;
|
||||
|
||||
if (segments[2] === preferredIdentifier) return null;
|
||||
|
||||
segments[2] = preferredIdentifier;
|
||||
|
||||
return `${segments.join("/")}${url.search}`;
|
||||
}
|
||||
|
||||
@@ -429,26 +429,6 @@ export function matchProfileWeapons(eb: ExpressionBuilder<DB, any>) {
|
||||
);
|
||||
}
|
||||
|
||||
/** User profile weapons (from UserWeapon) with TenStarWeapon join. Correlates on "User"."id". */
|
||||
export function userProfileWeapons(eb: ExpressionBuilder<DB, any>) {
|
||||
return jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("UserWeapon")
|
||||
.leftJoin("TenStarWeapon", (join) =>
|
||||
join
|
||||
.onRef("TenStarWeapon.userId", "=", "UserWeapon.userId")
|
||||
.onRef("TenStarWeapon.weaponSplId", "=", "UserWeapon.weaponSplId"),
|
||||
)
|
||||
.select([
|
||||
"UserWeapon.weaponSplId",
|
||||
"UserWeapon.isFavorite",
|
||||
TEN_STAR_CASE.as("isTenStar"),
|
||||
])
|
||||
.whereRef("UserWeapon.userId", "=", "User.id")
|
||||
.orderBy("UserWeapon.order", "asc"),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Name shown inside tournaments: `User.tournamentName` falling back to `username`.
|
||||
* Prefer `commonUserSelect(eb, { inTournament: true })` when selecting the common user fields.
|
||||
|
||||
7
changelog/2026-09-05-one-weapon-pool.md
Normal file
7
changelog/2026-09-05-one-weapon-pool.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: feature
|
||||
---
|
||||
One weapon pool instead of two
|
||||
|
||||
- The weapon pool of the profile edit page is gone; the weapon pool from your match profile (in settings) is now the one and only weapon pool
|
||||
- It is what shows on your profile page's weapon pool widget, on team pages, on LFG posts, in build sorting and in the public API
|
||||
4
changelog/2026-09-05-user-page-canonical-url.md
Normal file
4
changelog/2026-09-05-user-page-canonical-url.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
type: feature
|
||||
---
|
||||
User pages opened via a link with a user ID or Discord ID now redirect to the profile's custom URL
|
||||
11
changelog/2026-09-05-widget-profile-for-everyone.md
Normal file
11
changelog/2026-09-05-widget-profile-for-everyone.md
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
type: feature
|
||||
---
|
||||
New user profile page now out for everyone and the default
|
||||
|
||||
- Default layout roughly equivalent to old page: weapon pool, X Rank peaks, badges, bio, teams, verified social links, sensitivity and member number
|
||||
- Customize the widgets to build your own personal page
|
||||
- Still Supporter exclusive: custom colors and some widgets (markdown bio, links, favorite stage, game badges, unverified peak XP and supporter since). Supporters also get more widget slots: 6 main and 7 side instead of 4 and 5.
|
||||
- Bios written in markdown now render formatted on the Plus Server voting page too
|
||||
- Battlefy account name is no longer part of the profile
|
||||
- The setting for showing your Discord username is gone. It is now controlled by not enabling the social links widget on your profile.
|
||||
@@ -148,7 +148,7 @@ test.describe("Admin panel", () => {
|
||||
|
||||
const userPage = new UserPage(page);
|
||||
await userPage.goto(NZAP_TEST_DISCORD_ID);
|
||||
await expect(userPage.locators.placementsBox).toBeVisible();
|
||||
await expect(userPage.widget("x-rank-peaks")).toBeVisible();
|
||||
const playerPage = await userPage.openPlacements();
|
||||
await expect(playerPage.locators.heading).toBeVisible();
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ test.describe("Builds", () => {
|
||||
await buildForm.form.check("isPrivate");
|
||||
await buildForm.form.submit();
|
||||
|
||||
await expect(userBuilds.locators.buildsTab).toContainText("Builds (2)");
|
||||
await expect(userBuilds.locators.buildCards).toHaveCount(2);
|
||||
await expect(userBuilds.buildCard(0).root).toContainText("Private");
|
||||
|
||||
const buildIdAfter = await userBuilds.buildId(0);
|
||||
@@ -101,7 +101,7 @@ test.describe("Builds", () => {
|
||||
|
||||
await impersonate(page, NZAP_TEST_ID);
|
||||
await userBuilds.goto(ADMIN_DISCORD_ID);
|
||||
await expect(userBuilds.locators.buildsTab).toContainText("Builds (1)");
|
||||
await expect(userBuilds.locators.buildCards).toHaveCount(1);
|
||||
await expect(userBuilds.buildCard(0).root).not.toContainText("Private");
|
||||
});
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ export class UserBuildsPage {
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.locators = {
|
||||
buildsTab: page.getByTestId("user-builds-tab"),
|
||||
changeSortingButton: page.getByTestId("change-sorting-button"),
|
||||
buildCards: page.getByTestId("build-card"),
|
||||
editBuildLinks: page.getByTestId("edit-build"),
|
||||
|
||||
@@ -8,15 +8,10 @@ import { createFormHelpers } from "../../helpers/playwright-form";
|
||||
export class UserEditProfilePage {
|
||||
private readonly page: Page;
|
||||
readonly form;
|
||||
readonly locators;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.form = createFormHelpers(page, userEditProfileBaseSchema);
|
||||
this.locators = {
|
||||
badgesSelector: page.getByTestId("badges-selector"),
|
||||
badgeDisplay: page.getByTestId("badge-display"),
|
||||
};
|
||||
}
|
||||
|
||||
async goto(discordId: string) {
|
||||
@@ -26,18 +21,6 @@ export class UserEditProfilePage {
|
||||
});
|
||||
}
|
||||
|
||||
async selectFavoriteBadge(badgeId: number) {
|
||||
await this.locators.badgesSelector.selectOption(String(badgeId));
|
||||
}
|
||||
|
||||
async selectStickSens(value: string) {
|
||||
await this.page.getByLabel("R-stick sens").selectOption(value);
|
||||
}
|
||||
|
||||
async selectMotionSens(value: string) {
|
||||
await this.page.getByLabel("Motion sens").selectOption(value);
|
||||
}
|
||||
|
||||
async selectCountry(name: string) {
|
||||
await this.page.getByLabel("Country").click();
|
||||
await this.page.getByRole("combobox", { name: "Search" }).fill(name);
|
||||
|
||||
@@ -11,6 +11,8 @@ export class UserEditWidgetsPage {
|
||||
this.page = page;
|
||||
this.locators = {
|
||||
saveButton: page.getByRole("button", { name: "Save", exact: true }),
|
||||
badgesSelector: page.getByTestId("badges-selector"),
|
||||
badgeDisplay: page.getByTestId("badge-display"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,7 +25,31 @@ export class UserEditWidgetsPage {
|
||||
|
||||
/** Adds a widget from the gallery by its id, e.g. `"bio"` or `"join-date"`. */
|
||||
async addWidget(widgetId: string) {
|
||||
await this.page.getByTestId(`add-widget-${widgetId}`).click();
|
||||
await this.addWidgetButton(widgetId).click();
|
||||
}
|
||||
|
||||
addWidgetButton(widgetId: string) {
|
||||
return this.page.getByTestId(`add-widget-${widgetId}`);
|
||||
}
|
||||
|
||||
/** Shown in place of the add button for a widget the user is not a supporter for. */
|
||||
supporterOnlyLabel(widgetId: string) {
|
||||
return this.page.getByTestId(`supporter-only-${widgetId}`);
|
||||
}
|
||||
|
||||
/** Removes one of the selected widgets by its id. */
|
||||
async removeWidget(widgetId: string) {
|
||||
await this.page.getByTestId(`remove-widget-${widgetId}`).click();
|
||||
}
|
||||
|
||||
/** Expands the settings of one of the selected widgets. */
|
||||
async openWidgetSettings(widgetId: string) {
|
||||
await this.page.getByTestId(`widget-settings-${widgetId}`).click();
|
||||
}
|
||||
|
||||
/** Picks one favorite badge in the badges widget's settings. */
|
||||
async selectFavoriteBadge(badgeId: number) {
|
||||
await this.locators.badgesSelector.selectOption(String(badgeId));
|
||||
}
|
||||
|
||||
/** Fills the bio widget's settings, expanded right after adding it. */
|
||||
|
||||
@@ -15,17 +15,14 @@ export class UserPage {
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.locators = {
|
||||
mainTeamLink: page.getByTestId("main-team-link"),
|
||||
secondaryTeamsTrigger: page.getByTestId("secondary-team-trigger"),
|
||||
placementsBox: page.getByTestId("placements-box"),
|
||||
badgeDisplay: page.getByTestId("badge-display"),
|
||||
badgePaginationButtons: page.getByTestId("badge-pagination-button"),
|
||||
editProfileButton: page.getByText("Edit", { exact: true }),
|
||||
editProfileButton: page.getByRole("link", { name: "Edit Profile" }),
|
||||
editWidgetsButton: page.getByRole("link", { name: "Edit Widgets" }),
|
||||
seasonsTab: page.getByTestId("user-seasons-tab"),
|
||||
// the icon nav has a desktop and a mobile copy, only one of them shown
|
||||
seasonsTab: page.locator('[data-testid="user-seasons-tab"]:visible'),
|
||||
vodsTab: page.locator('[data-testid="user-vods-tab"]:visible'),
|
||||
resultsTab: page.getByTestId("user-results-tab"),
|
||||
resultsTab: page.locator('[data-testid="user-results-tab"]:visible'),
|
||||
seasonsTournamentResult: page.getByTestId("seasons-tournament-result"),
|
||||
};
|
||||
}
|
||||
@@ -34,6 +31,11 @@ export class UserPage {
|
||||
await navigate({ page: this.page, url: userPage({ discordId }) });
|
||||
}
|
||||
|
||||
/** Navigates with any of the identifiers the page accepts: user id, Discord id or custom URL. */
|
||||
async gotoWithIdentifier(identifier: string | number) {
|
||||
await navigate({ page: this.page, url: `/u/${identifier}` });
|
||||
}
|
||||
|
||||
badgeImage(displayName: string) {
|
||||
return this.page.getByAltText(displayName, { exact: true });
|
||||
}
|
||||
@@ -55,11 +57,21 @@ export class UserPage {
|
||||
return this.page.getByText(content, { exact: true });
|
||||
}
|
||||
|
||||
/** The title of a widget on the new (widgets-enabled) profile. */
|
||||
/** The title of a profile widget. */
|
||||
widgetHeading(name: string) {
|
||||
return this.page.getByRole("heading", { name, exact: true });
|
||||
}
|
||||
|
||||
/** A profile widget by its id. */
|
||||
widget(widgetId: string) {
|
||||
return this.page.getByTestId(`widget-${widgetId}`);
|
||||
}
|
||||
|
||||
/** The teams the user is a member of, their main team first. */
|
||||
teamLinks() {
|
||||
return this.widget("teams").getByRole("link");
|
||||
}
|
||||
|
||||
usernameHeading(username: string) {
|
||||
return this.page.getByRole("heading", { name: username });
|
||||
}
|
||||
@@ -70,16 +82,21 @@ export class UserPage {
|
||||
}
|
||||
|
||||
async openMainTeam() {
|
||||
await this.locators.mainTeamLink.click();
|
||||
await this.teamLinks().first().click();
|
||||
return new TeamPage(this.page);
|
||||
}
|
||||
|
||||
/** The X Rank summary, shown only for a user with a linked player. */
|
||||
async openPlacements() {
|
||||
await this.locators.placementsBox.click();
|
||||
await this.widget("x-rank-peaks").getByRole("link").click();
|
||||
return new TopSearchPlayerPage(this.page);
|
||||
}
|
||||
|
||||
/** Returns to the profile from a sub page via its header back button. */
|
||||
async backToProfile() {
|
||||
await this.page.getByRole("link", { name: "Back to profile" }).click();
|
||||
}
|
||||
|
||||
async openSeasons() {
|
||||
await this.locators.seasonsTab.click();
|
||||
}
|
||||
|
||||
@@ -349,8 +349,8 @@ test.describe("Team page", () => {
|
||||
const user = new UserPage(page);
|
||||
await user.goto(ADMIN_DISCORD_ID);
|
||||
|
||||
await expect(user.locators.secondaryTeamsTrigger).toBeVisible();
|
||||
await expect(user.locators.mainTeamLink).not.toContainText(TEAM_NAME);
|
||||
await expect(user.teamLinks()).toHaveCount(2);
|
||||
await expect(user.teamLinks().first()).not.toContainText(TEAM_NAME);
|
||||
|
||||
const mainTeam = await user.openMainTeam();
|
||||
|
||||
@@ -360,8 +360,8 @@ test.describe("Team page", () => {
|
||||
|
||||
await user.goto(ADMIN_DISCORD_ID);
|
||||
|
||||
await isNotVisible(user.locators.secondaryTeamsTrigger);
|
||||
await expect(user.locators.mainTeamLink).toContainText(TEAM_NAME);
|
||||
await expect(user.teamLinks()).toHaveCount(1);
|
||||
await expect(user.teamLinks().first()).toContainText(TEAM_NAME);
|
||||
});
|
||||
|
||||
test("makes another user editor, who can edit the page & becomes owner after the original leaves", async ({
|
||||
|
||||
@@ -155,6 +155,8 @@ test.describe("Tournament bracket multi stage", () => {
|
||||
await userPage.openSeasons();
|
||||
await expect(userPage.locators.seasonsTournamentResult).toBeVisible();
|
||||
|
||||
await userPage.backToProfile();
|
||||
|
||||
const userResults = await userPage.openResults();
|
||||
await expect(
|
||||
userResults.locators.tournamentNameCells.first(),
|
||||
|
||||
@@ -55,6 +55,9 @@ test.describe("Trophies", () => {
|
||||
|
||||
const trophy = await factories.TrophyFactory.create({ name: TROPHY_NAME });
|
||||
const tournament = await playTrophyTournament(factories, trophy.id);
|
||||
await factories.UserFactory.grant(ADMIN_ID, {
|
||||
widgets: [{ id: "trophies-owned" }],
|
||||
});
|
||||
|
||||
await impersonate(page);
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types";
|
||||
import type { Factories } from "./helpers/factories";
|
||||
import { expect, impersonate, isNotVisible, test } from "./helpers/playwright";
|
||||
import { SettingsPage } from "./pages/settings/settings-page";
|
||||
import { UserEditProfilePage } from "./pages/user/user-edit-profile-page";
|
||||
import { UserEditWidgetsPage } from "./pages/user/user-edit-widgets-page";
|
||||
import { UserPage } from "./pages/user/user-page";
|
||||
import { UserSeasonsPage } from "./pages/user/user-seasons-page";
|
||||
|
||||
@@ -67,11 +67,14 @@ test.describe("User page", () => {
|
||||
|
||||
await impersonate(page, NZAP_TEST_ID);
|
||||
|
||||
const editProfile = new UserEditProfilePage(page);
|
||||
await editProfile.goto(NZAP_TEST_DISCORD_ID);
|
||||
const editWidgets = new UserEditWidgetsPage(page);
|
||||
await editWidgets.goto(NZAP_TEST_DISCORD_ID);
|
||||
|
||||
await editProfile.selectFavoriteBadge(firstBadge.id);
|
||||
await editProfile.save();
|
||||
// the default layout's bio widget is empty, and an empty bio blocks saving
|
||||
await editWidgets.removeWidget("bio");
|
||||
await editWidgets.openWidgetSettings("badges-owned");
|
||||
await editWidgets.selectFavoriteBadge(firstBadge.id);
|
||||
await editWidgets.save();
|
||||
|
||||
const userPage = new UserPage(page);
|
||||
await userPage.goto(NZAP_TEST_DISCORD_ID);
|
||||
@@ -94,13 +97,15 @@ test.describe("User page", () => {
|
||||
|
||||
await impersonate(page);
|
||||
|
||||
const editProfile = new UserEditProfilePage(page);
|
||||
await editProfile.goto(ADMIN_DISCORD_ID);
|
||||
const editWidgets = new UserEditWidgetsPage(page);
|
||||
await editWidgets.goto(ADMIN_DISCORD_ID);
|
||||
|
||||
await editProfile.selectFavoriteBadge(badges[0].id);
|
||||
await expect(editProfile.locators.badgeDisplay).toBeVisible();
|
||||
await editProfile.selectFavoriteBadge(badges[1].id);
|
||||
await editProfile.save();
|
||||
await editWidgets.removeWidget("bio");
|
||||
await editWidgets.openWidgetSettings("badges-owned");
|
||||
await editWidgets.selectFavoriteBadge(badges[0].id);
|
||||
await expect(editWidgets.locators.badgeDisplay).toBeVisible();
|
||||
await editWidgets.selectFavoriteBadge(badges[1].id);
|
||||
await editWidgets.save();
|
||||
|
||||
const userPage = new UserPage(page);
|
||||
await userPage.goto(ADMIN_DISCORD_ID);
|
||||
@@ -123,16 +128,11 @@ test.describe("User page", () => {
|
||||
const editProfile = await userPage.openEditProfile();
|
||||
|
||||
await editProfile.form.fill("inGameName", "Lean#1234");
|
||||
await editProfile.selectStickSens("0");
|
||||
await editProfile.selectMotionSens("-50");
|
||||
await editProfile.selectCountry("Sweden");
|
||||
await editProfile.form.fill("bio", "My awesome bio");
|
||||
await editProfile.save();
|
||||
|
||||
await expect(userPage.flag("SE")).toBeVisible();
|
||||
await expect(userPage.text("My awesome bio")).toBeVisible();
|
||||
await expect(userPage.text("Lean#1234")).toBeVisible();
|
||||
await expect(userPage.text("Motion -5 / Stick 0")).toBeVisible();
|
||||
});
|
||||
|
||||
test("customizes theme colors and resets them", async ({
|
||||
@@ -198,12 +198,14 @@ test.describe("User page", () => {
|
||||
await isNotVisible(seasonsPage.locators.downloadButton);
|
||||
});
|
||||
|
||||
test("edits weapon pool", async ({ page, factories }) => {
|
||||
test("shows the match profile weapon pool", async ({ page, factories }) => {
|
||||
await factories.UserFactory.grant(ADMIN_ID, {
|
||||
weapons: ([200, 1100, 2000, 4000] as const).map((weaponSplId) => ({
|
||||
weaponSplId,
|
||||
isFavorite: 0 as const,
|
||||
})),
|
||||
matchProfile: {
|
||||
weaponPool: ([200, 1100, 2000, 4000] as const).map((id) => ({
|
||||
id,
|
||||
isFavorite: false,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
await impersonate(page);
|
||||
@@ -214,16 +216,6 @@ test.describe("User page", () => {
|
||||
for (const [i, id] of [200, 1100, 2000, 4000].entries()) {
|
||||
await expect(userPage.weaponPoolImage(id, i + 1)).toBeVisible();
|
||||
}
|
||||
|
||||
const editProfile = await userPage.openEditProfile();
|
||||
|
||||
await editProfile.form.selectWeapons("weapons", ["Range Blaster"]);
|
||||
await editProfile.deleteWeapon(/Inkbrush/);
|
||||
await editProfile.save();
|
||||
|
||||
for (const [i, id] of [200, 2000, 4000, 220].entries()) {
|
||||
await expect(userPage.weaponPoolImage(id, i + 1)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("chooses result highlights which the results list then shows by default", async ({
|
||||
@@ -281,10 +273,6 @@ test.describe("User page", () => {
|
||||
page,
|
||||
factories,
|
||||
}) => {
|
||||
await factories.UserFactory.grant(ADMIN_ID, {
|
||||
patronTier: 2,
|
||||
preferences: { newProfileEnabled: true },
|
||||
});
|
||||
await factories.VodFactory.createMany(2, (index) => ({
|
||||
submitterUserId: ADMIN_ID,
|
||||
pov: { type: "USER" as const, userId: ADMIN_ID },
|
||||
@@ -320,21 +308,21 @@ test.describe("User page", () => {
|
||||
const userPage = new UserPage(page);
|
||||
await userPage.goto(ADMIN_DISCORD_ID);
|
||||
|
||||
// no team, so the default layout's teams widget has nothing to show
|
||||
await isNotVisible(userPage.widgetHeading("Teams"));
|
||||
|
||||
const editWidgets = await userPage.openEditWidgets();
|
||||
// the default layout is what an untouched profile starts editing from
|
||||
await editWidgets.removeWidget("bio");
|
||||
await editWidgets.addWidget("bio");
|
||||
await editWidgets.fillBio("Reformed Hydra main");
|
||||
await editWidgets.addWidget("join-date");
|
||||
await editWidgets.save();
|
||||
|
||||
await expect(userPage.widgetHeading("Bio")).toBeVisible();
|
||||
await expect(
|
||||
userPage.text("Reformed Hydra main").filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
await expect(userPage.text("Reformed Hydra main")).toBeVisible();
|
||||
await expect(userPage.widgetHeading("Member #")).toBeVisible();
|
||||
// admin is the first user created, so their join order is 1
|
||||
await expect(
|
||||
userPage.exactText("#1").filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
await expect(userPage.exactText("#1")).toBeVisible();
|
||||
|
||||
const vodsPage = await userPage.openVods();
|
||||
await expect(vodsPage.vodTitle("Ranked grind episode 1")).toBeVisible();
|
||||
@@ -356,6 +344,53 @@ test.describe("User page", () => {
|
||||
await seasonsPage.openStatsTab("Teammates");
|
||||
await expect(seasonsPage.playerLink("N-ZAP")).toBeVisible();
|
||||
});
|
||||
|
||||
test("gates supporter only widgets behind supporter status", async ({
|
||||
page,
|
||||
factories,
|
||||
}) => {
|
||||
await impersonate(page);
|
||||
|
||||
const editWidgets = new UserEditWidgetsPage(page);
|
||||
await editWidgets.goto(ADMIN_DISCORD_ID);
|
||||
|
||||
await expect(editWidgets.supporterOnlyLabel("bio-md")).toBeVisible();
|
||||
await isNotVisible(editWidgets.addWidgetButton("bio-md"));
|
||||
|
||||
await factories.UserFactory.grant(ADMIN_ID, { patronTier: 2 });
|
||||
await editWidgets.goto(ADMIN_DISCORD_ID);
|
||||
|
||||
await isNotVisible(editWidgets.supporterOnlyLabel("bio-md"));
|
||||
await editWidgets.removeWidget("bio");
|
||||
await editWidgets.addWidget("bio-md");
|
||||
await editWidgets.fillBio("**Reformed** Hydra main");
|
||||
await editWidgets.save();
|
||||
|
||||
const userPage = new UserPage(page);
|
||||
await expect(userPage.widget("bio-md").locator("strong")).toHaveText(
|
||||
"Reformed",
|
||||
);
|
||||
});
|
||||
|
||||
test("redirects to the preferred identifier", async ({ page, factories }) => {
|
||||
const customUrl = "zapfish";
|
||||
await factories.UserFactory.updateProfile(NZAP_TEST_ID, { customUrl });
|
||||
|
||||
const userPage = new UserPage(page);
|
||||
|
||||
await userPage.gotoWithIdentifier(NZAP_TEST_ID);
|
||||
await expect(page).toHaveURL(`/u/${customUrl}`);
|
||||
|
||||
await userPage.gotoWithIdentifier(NZAP_TEST_DISCORD_ID);
|
||||
await expect(page).toHaveURL(`/u/${customUrl}`);
|
||||
|
||||
await userPage.gotoWithIdentifier(customUrl);
|
||||
await expect(page).toHaveURL(`/u/${customUrl}`);
|
||||
|
||||
// without a custom URL the Discord id is the preferred identifier
|
||||
await userPage.gotoWithIdentifier(ADMIN_ID);
|
||||
await expect(page).toHaveURL(`/u/${ADMIN_DISCORD_ID}`);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -314,6 +314,10 @@
|
||||
"support.perk.userShortLink": "",
|
||||
"support.perk.userShortLink.extra": "",
|
||||
"support.perk.customizedColorsUser": "Tilpas farver på brugerprofil",
|
||||
"support.perk.supporterWidgets": "",
|
||||
"support.perk.supporterWidgets.extra": "",
|
||||
"support.perk.moreWidgets": "",
|
||||
"support.perk.moreWidgets.extra": "",
|
||||
"support.perk.customAvatar": "",
|
||||
"support.perk.customAvatar.extra": "",
|
||||
"support.perk.favoriteBadges": "",
|
||||
|
||||
@@ -272,28 +272,19 @@
|
||||
"labels.profileCustomName": "Brugerdefineret Navn",
|
||||
"labels.profileCustomUrl": "Brugerdefineret URL",
|
||||
"labels.inGameName": "Splatoon 3 Brugernavn",
|
||||
"labels.profileBattlefy": "Battlefy brugernavn",
|
||||
"labels.profileMotionSens": "Bevægelsesfølsomhed",
|
||||
"labels.profileStickSens": "Styrepindsfølsomhed",
|
||||
"labels.profileCountry": "Land/region",
|
||||
"labels.profileFavoriteBadges": "",
|
||||
"labels.profileShowDiscordUniqueName": "Vis Discord-brugernavn",
|
||||
"labels.profileCommissionsOpen": "Åben for bestillinger",
|
||||
"labels.profileCommissionText": "info om bestilling",
|
||||
"labels.profileNewProfileEnabled": "",
|
||||
"bottomTexts.profileCustomAvatar": "",
|
||||
"bottomTexts.profileCustomName": "Hvis feltet ikke udfyldes bruges dit discordbrugernavn",
|
||||
"bottomTexts.profileCustomUrl": "",
|
||||
"bottomTexts.profileInGameName": "",
|
||||
"bottomTexts.profileBattlefy": "Battlefy-brugernavn bruges til seeding og bekræftelse i nogle turneringer",
|
||||
"bottomTexts.profileShowDiscordUniqueName": "Vil du gøre dit unikke Discord-brugernavn synligt for offentligheden?",
|
||||
"bottomTexts.profileCommissionsOpen": "",
|
||||
"bottomTexts.profileCommissionText": "Pris, åbne pladser eller andre relevante informationer der er relateret til at afgive en bestilling til dig.",
|
||||
"bottomTexts.profileNewProfileEnabled": "",
|
||||
"errors.profileCustomUrlStrangeChar": "Brugerdefineret URL må ikke indeholde specialtegn (Gælder også æ, ø og å)",
|
||||
"errors.profileCustomUrlNumbers": "Brugerdefineret URL må ikke kun indeholde numre",
|
||||
"errors.profileCustomUrlDuplicate": "Brugerdefineret URL er allerede i brug",
|
||||
"errors.profileSensBothOrNeither": "Bevægelsesfølsomhed kan ikke indstilles før at Styrepindsfølsomheden er indstillet",
|
||||
"errors.profileInGameName": "",
|
||||
"inGameName.addCharacter": "",
|
||||
"inGameName.categories.symbols": "",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"new.visibility.everyone": "Alle",
|
||||
"new.editOn": "Tilpas dette på din",
|
||||
"new.weaponPool.header": "Våbenpulje",
|
||||
"new.weaponPool.userProfile": "Brugerprofil",
|
||||
"new.weaponPool.matchProfile": "",
|
||||
"new.languages.header": "Sprog",
|
||||
"new.languages.placeholder": "",
|
||||
"new.languages.sqSettingsPage": "SendouQ opsætnings-side"
|
||||
|
||||
@@ -54,6 +54,8 @@
|
||||
"widgets.available": "",
|
||||
"widgets.mainSlot": "",
|
||||
"widgets.sideSlot": "",
|
||||
"widgets.supporterMax": "",
|
||||
"widgets.supporterOnly": "",
|
||||
"widgets.main": "",
|
||||
"widgets.side": "",
|
||||
"widgets.add": "",
|
||||
@@ -143,10 +145,6 @@
|
||||
"controllers.handheld": "",
|
||||
"stickSens": "Styrepindsfølsomhed",
|
||||
"motionSens": "Bevægelsesfølsomhed",
|
||||
"motion": "Bevægelse",
|
||||
"stick": "Styrepind",
|
||||
"sens": "Følsomhed",
|
||||
"usesPronouns": "",
|
||||
"discordExplanation": "Brugernavn, Profilbillede, Youtube-, Bluesky- og Twitch-konter er hentet via din Discord-konto. Se <1>FAQ</1> for yderligere information.",
|
||||
"results.placing": "Placering",
|
||||
"results.team": "Hold",
|
||||
|
||||
@@ -314,6 +314,10 @@
|
||||
"support.perk.userShortLink": "",
|
||||
"support.perk.userShortLink.extra": "",
|
||||
"support.perk.customizedColorsUser": "Farben anpassen (User-Seite)",
|
||||
"support.perk.supporterWidgets": "",
|
||||
"support.perk.supporterWidgets.extra": "",
|
||||
"support.perk.moreWidgets": "",
|
||||
"support.perk.moreWidgets.extra": "",
|
||||
"support.perk.customAvatar": "",
|
||||
"support.perk.customAvatar.extra": "",
|
||||
"support.perk.favoriteBadges": "",
|
||||
|
||||
@@ -272,28 +272,19 @@
|
||||
"labels.profileCustomName": "",
|
||||
"labels.profileCustomUrl": "Benutzerdefinierte URL",
|
||||
"labels.inGameName": "Name im Spiel",
|
||||
"labels.profileBattlefy": "",
|
||||
"labels.profileMotionSens": "Empfindlichkeit Bewegungssteuerung",
|
||||
"labels.profileStickSens": "Empfindlichkeit R-Stick",
|
||||
"labels.profileCountry": "Land/Region",
|
||||
"labels.profileFavoriteBadges": "",
|
||||
"labels.profileShowDiscordUniqueName": "",
|
||||
"labels.profileCommissionsOpen": "",
|
||||
"labels.profileCommissionText": "",
|
||||
"labels.profileNewProfileEnabled": "",
|
||||
"bottomTexts.profileCustomAvatar": "",
|
||||
"bottomTexts.profileCustomName": "",
|
||||
"bottomTexts.profileCustomUrl": "",
|
||||
"bottomTexts.profileInGameName": "",
|
||||
"bottomTexts.profileBattlefy": "",
|
||||
"bottomTexts.profileShowDiscordUniqueName": "",
|
||||
"bottomTexts.profileCommissionsOpen": "",
|
||||
"bottomTexts.profileCommissionText": "",
|
||||
"bottomTexts.profileNewProfileEnabled": "",
|
||||
"errors.profileCustomUrlStrangeChar": "Benutzerdefinierte URL kann nicht aus speziellen Zeichen bestehen",
|
||||
"errors.profileCustomUrlNumbers": "Benutzerdefinierte URL kann nicht nur aus Zahlen bestehen",
|
||||
"errors.profileCustomUrlDuplicate": "Diese Benutzerdefinierte URL wird bereits verwendet",
|
||||
"errors.profileSensBothOrNeither": "Empfindlichkeit der Bewegungssteuerung kann nur festgelegt werden, wenn Empfindlichkeit R-Stick festgelegt ist",
|
||||
"errors.profileInGameName": "",
|
||||
"inGameName.addCharacter": "",
|
||||
"inGameName.categories.symbols": "",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"new.visibility.everyone": "",
|
||||
"new.editOn": "",
|
||||
"new.weaponPool.header": "",
|
||||
"new.weaponPool.userProfile": "",
|
||||
"new.weaponPool.matchProfile": "",
|
||||
"new.languages.header": "",
|
||||
"new.languages.placeholder": "",
|
||||
"new.languages.sqSettingsPage": ""
|
||||
|
||||
@@ -54,6 +54,8 @@
|
||||
"widgets.available": "",
|
||||
"widgets.mainSlot": "",
|
||||
"widgets.sideSlot": "",
|
||||
"widgets.supporterMax": "",
|
||||
"widgets.supporterOnly": "",
|
||||
"widgets.main": "",
|
||||
"widgets.side": "",
|
||||
"widgets.add": "",
|
||||
@@ -143,10 +145,6 @@
|
||||
"controllers.handheld": "",
|
||||
"stickSens": "Empfindlichkeit R-Stick",
|
||||
"motionSens": "Empfindlichkeit Bewegungssteuerung",
|
||||
"motion": "Bewegungssteuerung",
|
||||
"stick": "Stick",
|
||||
"sens": "Empfindlichkeit",
|
||||
"usesPronouns": "",
|
||||
"discordExplanation": "Der Username, Profilbild, YouTube-, Bluesky- und Twitch-Konten stammen von deinem Discord-Konto. Mehr Infos in den <1>FAQ</1>.",
|
||||
"results.placing": "Platzierung",
|
||||
"results.team": "Team",
|
||||
|
||||
@@ -314,6 +314,10 @@
|
||||
"support.perk.userShortLink": "User page short link",
|
||||
"support.perk.userShortLink.extra": "Instead of e.g. sendou.ink/u/sendou you can also use snd.ink/sendou when linking to your user profile.",
|
||||
"support.perk.customizedColorsUser": "Customize colors (user page)",
|
||||
"support.perk.supporterWidgets": "Supporter-only profile widgets",
|
||||
"support.perk.supporterWidgets.extra": "Some profile widgets can only be added by supporters: markdown bio, custom links, favorite stage, in-game badges (big), unverified peak XP and supporter since.",
|
||||
"support.perk.moreWidgets": "More profile widgets",
|
||||
"support.perk.moreWidgets.extra": "Fit 6 main and 7 side widgets on your profile instead of the usual 4 and 5.",
|
||||
"support.perk.customAvatar": "Custom avatar",
|
||||
"support.perk.customAvatar.extra": "Upload a custom avatar to use instead of your Discord avatar.",
|
||||
"support.perk.favoriteBadges": "Set profile first page badges",
|
||||
|
||||
@@ -272,28 +272,19 @@
|
||||
"labels.profileCustomName": "Custom name",
|
||||
"labels.profileCustomUrl": "Custom URL",
|
||||
"labels.inGameName": "In-game name",
|
||||
"labels.profileBattlefy": "Battlefy account name",
|
||||
"labels.profileMotionSens": "Motion sens",
|
||||
"labels.profileStickSens": "R-stick sens",
|
||||
"labels.profileCountry": "Country/Region",
|
||||
"labels.profileFavoriteBadges": "Favorite badges",
|
||||
"labels.profileShowDiscordUniqueName": "Show Discord username",
|
||||
"labels.profileCommissionsOpen": "Commissions open",
|
||||
"labels.profileCommissionText": "Commission info",
|
||||
"labels.profileNewProfileEnabled": "New profile page",
|
||||
"bottomTexts.profileCustomAvatar": "Patrons (Supporter & above) can upload an image to be used instead of Discord avatar",
|
||||
"bottomTexts.profileCustomName": "If empty, your Discord display name is shown",
|
||||
"bottomTexts.profileCustomUrl": "For patrons (Supporter & above) short link is available. E.g. instead of sendou.ink/u/sendou, snd.ink/sendou can be used.",
|
||||
"bottomTexts.profileInGameName": "Format: Name#disc (e.g. Player#1234)",
|
||||
"bottomTexts.profileBattlefy": "Used for seeding and verification in some tournaments",
|
||||
"bottomTexts.profileShowDiscordUniqueName": "Show your Discord username publicly on your profile",
|
||||
"bottomTexts.profileCommissionsOpen": "Commissions automatically close after one month",
|
||||
"bottomTexts.profileCommissionText": "Price, slots open or other commission info",
|
||||
"bottomTexts.profileNewProfileEnabled": "Enable the new widget-based profile page (supporter only)",
|
||||
"errors.profileCustomUrlStrangeChar": "Custom URL can only contain letters, numbers, hyphens and underscores",
|
||||
"errors.profileCustomUrlNumbers": "Custom URL can't only contain numbers",
|
||||
"errors.profileCustomUrlDuplicate": "Someone is already using this custom URL",
|
||||
"errors.profileSensBothOrNeither": "Motion sens can't be set if R-stick sens isn't",
|
||||
"errors.profileInGameName": "Must match format: Name#disc (1-10 characters, #, 4-5 alphanumeric)",
|
||||
"inGameName.addCharacter": "Add special character",
|
||||
"inGameName.categories.symbols": "Symbols",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"new.visibility.everyone": "Everyone",
|
||||
"new.editOn": "Edit on your",
|
||||
"new.weaponPool.header": "Weapon pool",
|
||||
"new.weaponPool.userProfile": "user profile",
|
||||
"new.weaponPool.matchProfile": "match profile",
|
||||
"new.languages.header": "Languages",
|
||||
"new.languages.placeholder": "Select all that apply",
|
||||
"new.languages.sqSettingsPage": "SendouQ settings page"
|
||||
|
||||
@@ -54,6 +54,8 @@
|
||||
"widgets.available": "Gallery",
|
||||
"widgets.mainSlot": "Main Widgets",
|
||||
"widgets.sideSlot": "Side Widgets",
|
||||
"widgets.supporterMax": "(Supporter max: {{max}})",
|
||||
"widgets.supporterOnly": "Supporter only",
|
||||
"widgets.main": "Main",
|
||||
"widgets.side": "Side",
|
||||
"widgets.add": "Add",
|
||||
@@ -143,10 +145,6 @@
|
||||
"controllers.handheld": "Handheld",
|
||||
"stickSens": "R-stick sens",
|
||||
"motionSens": "Motion sens",
|
||||
"motion": "Motion",
|
||||
"stick": "Stick",
|
||||
"sens": "Sens",
|
||||
"usesPronouns": "Uses",
|
||||
"discordExplanation": "Username, profile picture, YouTube, Bluesky and Twitch accounts come from your Discord account. See <1>FAQ</1> for more information.",
|
||||
"results.placing": "Placing",
|
||||
"results.team": "Team",
|
||||
|
||||
@@ -314,6 +314,10 @@
|
||||
"support.perk.userShortLink": "Enlace corto de perfil",
|
||||
"support.perk.userShortLink.extra": "En lugar de sendou.ink/u/sendou también puedes usar snd.ink/sendou al enlazar a tu perfil de usuario.",
|
||||
"support.perk.customizedColorsUser": "Colores personalizados (perfil)",
|
||||
"support.perk.supporterWidgets": "",
|
||||
"support.perk.supporterWidgets.extra": "",
|
||||
"support.perk.moreWidgets": "",
|
||||
"support.perk.moreWidgets.extra": "",
|
||||
"support.perk.customAvatar": "Avatar personalizado",
|
||||
"support.perk.customAvatar.extra": "Sube un avatar personalizado para usarlo en lugar de tu avatar de Discord.",
|
||||
"support.perk.favoriteBadges": "Fijar insignias favoritas",
|
||||
|
||||
@@ -272,28 +272,19 @@
|
||||
"labels.profileCustomName": "Nombre personalizado",
|
||||
"labels.profileCustomUrl": "Enlace personalizado",
|
||||
"labels.inGameName": "Nombre en el juego",
|
||||
"labels.profileBattlefy": "Nombre de cuenta de Battlefy",
|
||||
"labels.profileMotionSens": "Sens del giroscopio",
|
||||
"labels.profileStickSens": "Sens de palanca",
|
||||
"labels.profileCountry": "País/Región",
|
||||
"labels.profileFavoriteBadges": "Insignias favoritas",
|
||||
"labels.profileShowDiscordUniqueName": "Mostrar usuario de Discord",
|
||||
"labels.profileCommissionsOpen": "Comisiones abiertas",
|
||||
"labels.profileCommissionText": "Info de comisiones",
|
||||
"labels.profileNewProfileEnabled": "Nueva página de perfil",
|
||||
"bottomTexts.profileCustomAvatar": "Los mecenas (Supporter y superior) pueden subir una imagen para usarla en lugar del avatar de Discord",
|
||||
"bottomTexts.profileCustomName": "Si está vacío, se mostrará tu nombre de Discord",
|
||||
"bottomTexts.profileCustomUrl": "Para los mecenas (Supporter y superior) hay un enlace corto disponible. Ej. en lugar de sendou.ink/u/sendou, se puede usar snd.ink/sendou.",
|
||||
"bottomTexts.profileInGameName": "Formato: Nombre#disc (ej. Jugador#1234)",
|
||||
"bottomTexts.profileBattlefy": "Se usa para el seed y la verificación en algunos torneos",
|
||||
"bottomTexts.profileShowDiscordUniqueName": "¿Mostrar tu nombre de Discord públicamente?",
|
||||
"bottomTexts.profileCommissionsOpen": "Las comisiones se cierran automáticamente después de un mes",
|
||||
"bottomTexts.profileCommissionText": "Precio, espacios abiertos, o cualquier otra información sobre tus comisiones.",
|
||||
"bottomTexts.profileNewProfileEnabled": "Habilitar la nueva página de perfil basada en widgets (solo para supporter)",
|
||||
"errors.profileCustomUrlStrangeChar": "Enlace personalizado no puede contener caracteres especiales",
|
||||
"errors.profileCustomUrlNumbers": "El enlace personalizado no puede ser solo números",
|
||||
"errors.profileCustomUrlDuplicate": "Alguien ya tiene ese enlace personalizado",
|
||||
"errors.profileSensBothOrNeither": "No se puede configurar la sensibilidad del giroscopio sin configurar la de la palanca derecha",
|
||||
"errors.profileInGameName": "Debe coincidir con el formato: Nombre#disc (1-10 caracteres, #, 4-5 alfanuméricos)",
|
||||
"inGameName.addCharacter": "Añadir carácter especial",
|
||||
"inGameName.categories.symbols": "Símbolos",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"new.visibility.everyone": "Todo el mundo",
|
||||
"new.editOn": "Editar en tu",
|
||||
"new.weaponPool.header": "Grupo de armas",
|
||||
"new.weaponPool.userProfile": "perfil de usuario",
|
||||
"new.weaponPool.matchProfile": "",
|
||||
"new.languages.header": "Idiomas",
|
||||
"new.languages.placeholder": "Elegir los que correspondan",
|
||||
"new.languages.sqSettingsPage": "página de ajustes de SendouQ"
|
||||
|
||||
@@ -54,6 +54,8 @@
|
||||
"widgets.available": "Galería",
|
||||
"widgets.mainSlot": "Widgets principales",
|
||||
"widgets.sideSlot": "Widgets laterales",
|
||||
"widgets.supporterMax": "",
|
||||
"widgets.supporterOnly": "",
|
||||
"widgets.main": "Principal",
|
||||
"widgets.side": "Lateral",
|
||||
"widgets.add": "Añadir",
|
||||
@@ -143,10 +145,6 @@
|
||||
"controllers.handheld": "Modo portátil",
|
||||
"stickSens": "Sensibilidad de palanca",
|
||||
"motionSens": "Sensibilidad del giroscopio",
|
||||
"motion": "Giroscopio",
|
||||
"stick": "Palanca",
|
||||
"sens": "Sensibilidad",
|
||||
"usesPronouns": "Usa",
|
||||
"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.",
|
||||
"results.placing": "Lugar",
|
||||
"results.team": "Equipo",
|
||||
|
||||
@@ -314,6 +314,10 @@
|
||||
"support.perk.userShortLink": "Enlace corto de perfil",
|
||||
"support.perk.userShortLink.extra": "En lugar de sendou.ink/u/sendou también puedes usar snd.ink/sendou al enlazar a tu perfil de usuario.",
|
||||
"support.perk.customizedColorsUser": "Colores personalizados (perfil)",
|
||||
"support.perk.supporterWidgets": "",
|
||||
"support.perk.supporterWidgets.extra": "",
|
||||
"support.perk.moreWidgets": "",
|
||||
"support.perk.moreWidgets.extra": "",
|
||||
"support.perk.customAvatar": "Avatar personalizado",
|
||||
"support.perk.customAvatar.extra": "Sube un avatar personalizado para usarlo en lugar de tu avatar de Discord.",
|
||||
"support.perk.favoriteBadges": "Fijar insignias favoritas",
|
||||
|
||||
@@ -272,28 +272,19 @@
|
||||
"labels.profileCustomName": "Nombre personalizado",
|
||||
"labels.profileCustomUrl": "Enlace personalizado",
|
||||
"labels.inGameName": "Nombre en el juego",
|
||||
"labels.profileBattlefy": "Nombre de cuenta de Battlefy",
|
||||
"labels.profileMotionSens": "Sens del giroscopio",
|
||||
"labels.profileStickSens": "Sens de palanca",
|
||||
"labels.profileCountry": "País/Región",
|
||||
"labels.profileFavoriteBadges": "Insignias favoritas",
|
||||
"labels.profileShowDiscordUniqueName": "Mostrar usuario de Discord",
|
||||
"labels.profileCommissionsOpen": "Comisiones abiertas",
|
||||
"labels.profileCommissionText": "Info de comisiones",
|
||||
"labels.profileNewProfileEnabled": "Nueva página de perfil",
|
||||
"bottomTexts.profileCustomAvatar": "Los mecenas (Supporter y superior) pueden subir una imagen para usarla en lugar del avatar de Discord",
|
||||
"bottomTexts.profileCustomName": "Si vacío, se mostrará tu nombre de Discord",
|
||||
"bottomTexts.profileCustomUrl": "Para los mecenas (Supporter y superior) hay un enlace corto disponible. Ej. en lugar de sendou.ink/u/sendou, se puede usar snd.ink/sendou.",
|
||||
"bottomTexts.profileInGameName": "Formato: Nombre#disc (ej. Jugador#1234)",
|
||||
"bottomTexts.profileBattlefy": "El nombre de tu cuenta de Battlefy se utiliza para la clasificación y verificación en algunos torneos.",
|
||||
"bottomTexts.profileShowDiscordUniqueName": "¿Mostrar tu nombre de Discord públicamente?",
|
||||
"bottomTexts.profileCommissionsOpen": "Las comisiones se cierran automáticamente después de un mes",
|
||||
"bottomTexts.profileCommissionText": "Precio, espacios abiertos, o cualquier otra información sobre tus comisiones.",
|
||||
"bottomTexts.profileNewProfileEnabled": "Habilitar la nueva página de perfil basada en widgets (solo para supporter)",
|
||||
"errors.profileCustomUrlStrangeChar": "Enlace personalizado no puede contener caracteres especiales",
|
||||
"errors.profileCustomUrlNumbers": "El enlace personalizado no puede ser solo números",
|
||||
"errors.profileCustomUrlDuplicate": "Alguien ya tiene ese enlace personalizado",
|
||||
"errors.profileSensBothOrNeither": "La sensibilidad del giroscopio no se puede configurar sin la sensibilidad de la palanca",
|
||||
"errors.profileInGameName": "Debe coincidir con el formato: Nombre#disc (1-10 caracteres, #, 4-5 alfanuméricos)",
|
||||
"inGameName.addCharacter": "Añadir carácter especial",
|
||||
"inGameName.categories.symbols": "Símbolos",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"new.visibility.everyone": "Todos",
|
||||
"new.editOn": "Editar en tu",
|
||||
"new.weaponPool.header": "Grupo de armas",
|
||||
"new.weaponPool.userProfile": "perfil de usuario",
|
||||
"new.weaponPool.matchProfile": "",
|
||||
"new.languages.header": "Idiomas",
|
||||
"new.languages.placeholder": "Elegir los que correspondan",
|
||||
"new.languages.sqSettingsPage": "página de ajustes de SendouQ"
|
||||
|
||||
@@ -54,6 +54,8 @@
|
||||
"widgets.available": "Galería",
|
||||
"widgets.mainSlot": "Widgets principales",
|
||||
"widgets.sideSlot": "Widgets laterales",
|
||||
"widgets.supporterMax": "",
|
||||
"widgets.supporterOnly": "",
|
||||
"widgets.main": "Principal",
|
||||
"widgets.side": "Lateral",
|
||||
"widgets.add": "Añadir",
|
||||
@@ -143,10 +145,6 @@
|
||||
"controllers.handheld": "Modo portátil",
|
||||
"stickSens": "Sens de palanca",
|
||||
"motionSens": "Sens del giroscopio",
|
||||
"motion": "Giroscopio",
|
||||
"stick": "Palanca",
|
||||
"sens": "Sens",
|
||||
"usesPronouns": "Usa",
|
||||
"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.",
|
||||
"results.placing": "Lugar",
|
||||
"results.team": "Equipo",
|
||||
|
||||
@@ -314,6 +314,10 @@
|
||||
"support.perk.userShortLink": "",
|
||||
"support.perk.userShortLink.extra": "",
|
||||
"support.perk.customizedColorsUser": "Personalisation des couleurs (page perso)",
|
||||
"support.perk.supporterWidgets": "",
|
||||
"support.perk.supporterWidgets.extra": "",
|
||||
"support.perk.moreWidgets": "",
|
||||
"support.perk.moreWidgets.extra": "",
|
||||
"support.perk.customAvatar": "",
|
||||
"support.perk.customAvatar.extra": "",
|
||||
"support.perk.favoriteBadges": "",
|
||||
|
||||
@@ -272,28 +272,19 @@
|
||||
"labels.profileCustomName": "",
|
||||
"labels.profileCustomUrl": "URL personnalisée",
|
||||
"labels.inGameName": "Pseudo en jeu",
|
||||
"labels.profileBattlefy": "",
|
||||
"labels.profileMotionSens": "Sensibilité du gyroscope",
|
||||
"labels.profileStickSens": "Sensibilité du stick droit",
|
||||
"labels.profileCountry": "Pays/région",
|
||||
"labels.profileFavoriteBadges": "",
|
||||
"labels.profileShowDiscordUniqueName": "Montrer le pseudo Discord",
|
||||
"labels.profileCommissionsOpen": "Commissions acceptées",
|
||||
"labels.profileCommissionText": "Info pour les commissions",
|
||||
"labels.profileNewProfileEnabled": "",
|
||||
"bottomTexts.profileCustomAvatar": "",
|
||||
"bottomTexts.profileCustomName": "",
|
||||
"bottomTexts.profileCustomUrl": "",
|
||||
"bottomTexts.profileInGameName": "",
|
||||
"bottomTexts.profileBattlefy": "",
|
||||
"bottomTexts.profileShowDiscordUniqueName": "",
|
||||
"bottomTexts.profileCommissionsOpen": "",
|
||||
"bottomTexts.profileCommissionText": "Prix, disponibilités et tout autres info nécéssaires",
|
||||
"bottomTexts.profileNewProfileEnabled": "",
|
||||
"errors.profileCustomUrlStrangeChar": "Votre URL personnalisée ne peut pas contenir de caractères spéciaux",
|
||||
"errors.profileCustomUrlNumbers": "Votre URL personnalisée ne peut pas contenir que des nombres",
|
||||
"errors.profileCustomUrlDuplicate": "Cette URL a déjà été choisie par quelqu'un",
|
||||
"errors.profileSensBothOrNeither": "La sensibilité du gyroscope ne peut pas être choisie si la sensibilité du stick droit ne l'est pas",
|
||||
"errors.profileInGameName": "",
|
||||
"inGameName.addCharacter": "",
|
||||
"inGameName.categories.symbols": "",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"new.visibility.everyone": "",
|
||||
"new.editOn": "",
|
||||
"new.weaponPool.header": "",
|
||||
"new.weaponPool.userProfile": "",
|
||||
"new.weaponPool.matchProfile": "",
|
||||
"new.languages.header": "",
|
||||
"new.languages.placeholder": "",
|
||||
"new.languages.sqSettingsPage": ""
|
||||
|
||||
@@ -54,6 +54,8 @@
|
||||
"widgets.available": "",
|
||||
"widgets.mainSlot": "",
|
||||
"widgets.sideSlot": "",
|
||||
"widgets.supporterMax": "",
|
||||
"widgets.supporterOnly": "",
|
||||
"widgets.main": "",
|
||||
"widgets.side": "",
|
||||
"widgets.add": "",
|
||||
@@ -143,10 +145,6 @@
|
||||
"controllers.handheld": "",
|
||||
"stickSens": "Sensibilité du stick droit",
|
||||
"motionSens": "Sensibilité du gyroscope",
|
||||
"motion": "Gyro",
|
||||
"stick": "Stick",
|
||||
"sens": "Sens",
|
||||
"usesPronouns": "",
|
||||
"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.",
|
||||
"results.placing": "Placement",
|
||||
"results.team": "Équipe",
|
||||
|
||||
@@ -314,6 +314,10 @@
|
||||
"support.perk.userShortLink": "Lien de la page de l'utilisateur plus court",
|
||||
"support.perk.userShortLink.extra": "Au lieu d'utiliser par exemple sendou.ink/u/sendou, vous pouvez également utiliser snd.ink/sendou lorsque vous créez un lien vers votre profil.",
|
||||
"support.perk.customizedColorsUser": "Personalisation des couleurs (page personnelle)",
|
||||
"support.perk.supporterWidgets": "",
|
||||
"support.perk.supporterWidgets.extra": "",
|
||||
"support.perk.moreWidgets": "",
|
||||
"support.perk.moreWidgets.extra": "",
|
||||
"support.perk.customAvatar": "",
|
||||
"support.perk.customAvatar.extra": "",
|
||||
"support.perk.favoriteBadges": "Choisissez le badge qui apparaitra en premier sur votre profil",
|
||||
|
||||
@@ -272,28 +272,19 @@
|
||||
"labels.profileCustomName": "Nom personnalisée",
|
||||
"labels.profileCustomUrl": "URL personnalisée",
|
||||
"labels.inGameName": "Pseudo en jeu",
|
||||
"labels.profileBattlefy": "Nom du compte Battlefy",
|
||||
"labels.profileMotionSens": "Sensibilité du gyroscope",
|
||||
"labels.profileStickSens": "Sensibilité du stick droit",
|
||||
"labels.profileCountry": "Pays/région",
|
||||
"labels.profileFavoriteBadges": "",
|
||||
"labels.profileShowDiscordUniqueName": "Montrer le pseudo Discord",
|
||||
"labels.profileCommissionsOpen": "Commissions acceptées",
|
||||
"labels.profileCommissionText": "Info pour les commissions",
|
||||
"labels.profileNewProfileEnabled": "",
|
||||
"bottomTexts.profileCustomAvatar": "",
|
||||
"bottomTexts.profileCustomName": "Si il n'est pas présent, votre pseudo discord est utilisé",
|
||||
"bottomTexts.profileCustomUrl": "Pour les Supporter patrons (& plus), les liens courts sont disponibles. Exemple: Au mieux de sendou.ink/u/sendou, snd.ink/sendou peut être utilisé.",
|
||||
"bottomTexts.profileInGameName": "",
|
||||
"bottomTexts.profileBattlefy": "Votre nom Battlefy est utiliser pour le seeding et la verification de certains tournois",
|
||||
"bottomTexts.profileShowDiscordUniqueName": "",
|
||||
"bottomTexts.profileCommissionsOpen": "",
|
||||
"bottomTexts.profileCommissionText": "Prix, disponibilités et tout autres info nécéssaires",
|
||||
"bottomTexts.profileNewProfileEnabled": "",
|
||||
"errors.profileCustomUrlStrangeChar": "Votre URL personnalisée ne peut pas contenir de caractères spéciaux",
|
||||
"errors.profileCustomUrlNumbers": "Votre URL personnalisée ne peut pas contenir que des nombres",
|
||||
"errors.profileCustomUrlDuplicate": "Cette URL a déjà été choisie par quelqu'un",
|
||||
"errors.profileSensBothOrNeither": "La sensibilité du gyroscope ne peut pas être choisie si la sensibilité du stick droit ne l'est pas",
|
||||
"errors.profileInGameName": "",
|
||||
"inGameName.addCharacter": "",
|
||||
"inGameName.categories.symbols": "",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"new.visibility.everyone": "Tout le monde",
|
||||
"new.editOn": "Modifier sur votre",
|
||||
"new.weaponPool.header": "Arme utilisé",
|
||||
"new.weaponPool.userProfile": "profil",
|
||||
"new.weaponPool.matchProfile": "",
|
||||
"new.languages.header": "Langues",
|
||||
"new.languages.placeholder": "Selectionner",
|
||||
"new.languages.sqSettingsPage": "page de paramètres SendouQ"
|
||||
|
||||
@@ -54,6 +54,8 @@
|
||||
"widgets.available": "",
|
||||
"widgets.mainSlot": "",
|
||||
"widgets.sideSlot": "",
|
||||
"widgets.supporterMax": "",
|
||||
"widgets.supporterOnly": "",
|
||||
"widgets.main": "",
|
||||
"widgets.side": "",
|
||||
"widgets.add": "",
|
||||
@@ -143,10 +145,6 @@
|
||||
"controllers.handheld": "",
|
||||
"stickSens": "Sensibilité du stick droit",
|
||||
"motionSens": "Sensibilité du gyroscope",
|
||||
"motion": "Gyro",
|
||||
"stick": "Stick",
|
||||
"sens": "Sens",
|
||||
"usesPronouns": "",
|
||||
"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.",
|
||||
"results.placing": "Placement",
|
||||
"results.team": "Équipe",
|
||||
|
||||
@@ -314,6 +314,10 @@
|
||||
"support.perk.userShortLink": "",
|
||||
"support.perk.userShortLink.extra": "",
|
||||
"support.perk.customizedColorsUser": "התאמה אישית של צבעים (דף משתמש)",
|
||||
"support.perk.supporterWidgets": "",
|
||||
"support.perk.supporterWidgets.extra": "",
|
||||
"support.perk.moreWidgets": "",
|
||||
"support.perk.moreWidgets.extra": "",
|
||||
"support.perk.customAvatar": "",
|
||||
"support.perk.customAvatar.extra": "",
|
||||
"support.perk.favoriteBadges": "",
|
||||
|
||||
@@ -272,28 +272,19 @@
|
||||
"labels.profileCustomName": "",
|
||||
"labels.profileCustomUrl": "כתובת URL מותאמת אישית",
|
||||
"labels.inGameName": "שם במשחק",
|
||||
"labels.profileBattlefy": "",
|
||||
"labels.profileMotionSens": "רגישות תנועה",
|
||||
"labels.profileStickSens": "רגישות סטיק ימני",
|
||||
"labels.profileCountry": "מדינה/אזור",
|
||||
"labels.profileFavoriteBadges": "",
|
||||
"labels.profileShowDiscordUniqueName": "הראה שם משתמש Discord",
|
||||
"labels.profileCommissionsOpen": "בקשות פתוחות",
|
||||
"labels.profileCommissionText": "מידע עבור בקשות",
|
||||
"labels.profileNewProfileEnabled": "",
|
||||
"bottomTexts.profileCustomAvatar": "",
|
||||
"bottomTexts.profileCustomName": "",
|
||||
"bottomTexts.profileCustomUrl": "",
|
||||
"bottomTexts.profileInGameName": "",
|
||||
"bottomTexts.profileBattlefy": "",
|
||||
"bottomTexts.profileShowDiscordUniqueName": "להראות את שם ה-Discord היחודי שלכם בפומבי?",
|
||||
"bottomTexts.profileCommissionsOpen": "",
|
||||
"bottomTexts.profileCommissionText": "מחיר, כמות בקשות או מידע אחר שקשור לבקשות אלכם",
|
||||
"bottomTexts.profileNewProfileEnabled": "",
|
||||
"errors.profileCustomUrlStrangeChar": "כתובת URL מותאמת אישית לא יכולה להכיל תווים מיוחדים",
|
||||
"errors.profileCustomUrlNumbers": "כתובת URL מותאמת אישית לא יכולה להכיל רק מספרים",
|
||||
"errors.profileCustomUrlDuplicate": "מישהו כבר משתמש בכתובת URL המותאמת אישית הזו",
|
||||
"errors.profileSensBothOrNeither": "לא ניתן להגדיר את רגישות התנועה אם רגישות הסטיק לא מוגדרת",
|
||||
"errors.profileInGameName": "",
|
||||
"inGameName.addCharacter": "",
|
||||
"inGameName.categories.symbols": "",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"new.visibility.everyone": "",
|
||||
"new.editOn": "",
|
||||
"new.weaponPool.header": "",
|
||||
"new.weaponPool.userProfile": "",
|
||||
"new.weaponPool.matchProfile": "",
|
||||
"new.languages.header": "",
|
||||
"new.languages.placeholder": "",
|
||||
"new.languages.sqSettingsPage": ""
|
||||
|
||||
@@ -54,6 +54,8 @@
|
||||
"widgets.available": "",
|
||||
"widgets.mainSlot": "",
|
||||
"widgets.sideSlot": "",
|
||||
"widgets.supporterMax": "",
|
||||
"widgets.supporterOnly": "",
|
||||
"widgets.main": "",
|
||||
"widgets.side": "",
|
||||
"widgets.add": "",
|
||||
@@ -143,10 +145,6 @@
|
||||
"controllers.handheld": "",
|
||||
"stickSens": "רגישות סטיק ימני",
|
||||
"motionSens": "רגישות תנועה",
|
||||
"motion": "תנועה",
|
||||
"stick": "סטיק",
|
||||
"sens": "רגישות",
|
||||
"usesPronouns": "",
|
||||
"discordExplanation": "שם משתמש, תמונת פרופיל, חשבונות YouTube, Bluesky ו-Twitch מגיעים מחשבון Discord שלך. ראו <1>שאלות נפוצות</1> למידע נוסף.",
|
||||
"results.placing": "מיקום",
|
||||
"results.team": "צוות",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user