Widget user profile for all (#3384)
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests and checks on push / run-checks-and-tests (push) Has been cancelled
Updates translation progress / update-translation-progress-issue (push) Has been cancelled

This commit is contained in:
Kalle
2026-09-05 18:49:36 +03:00
committed by GitHub
parent 51cbad4ed6
commit fa92e288e1
135 changed files with 1297 additions and 1716 deletions

View File

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

View File

@@ -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",

View File

@@ -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"),
]);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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`

View File

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

View File

@@ -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" */

View File

@@ -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) {

View File

@@ -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",

View File

@@ -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"),

View File

@@ -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),

View File

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

View File

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

View 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");
});
});

View File

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

View File

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

View File

@@ -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"),

View File

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

View File

@@ -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",

View File

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

View File

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

View File

@@ -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) {

View File

@@ -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 =

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()),

View 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,
);
});
});

View File

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

View File

@@ -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.

View 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

View 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

View 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.

View File

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

View File

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

View File

@@ -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"),

View File

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

View File

@@ -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. */

View File

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

View File

@@ -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 ({

View File

@@ -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(),

View File

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

View File

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

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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"

View File

@@ -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",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": ""

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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"

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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"

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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"

View File

@@ -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",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": ""

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "",

View File

@@ -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"

View File

@@ -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",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": ""

View File

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