diff --git a/app/components/icons/Battlefy.tsx b/app/components/icons/Battlefy.tsx deleted file mode 100644 index d7f01e73a..000000000 --- a/app/components/icons/Battlefy.tsx +++ /dev/null @@ -1,37 +0,0 @@ -export function BattlefyIcon() { - return ( - - - - - - - - - ); -} diff --git a/app/db/json-columns.ts b/app/db/json-columns.ts index a5c569365..111a8a013 100644 --- a/app/db/json-columns.ts +++ b/app/db/json-columns.ts @@ -33,7 +33,6 @@ export const JSON_COLUMNS: ReadonlySet = new Set([ "TournamentTeam.activeRosterUserIds", "User.buildSorting", "User.customTheme", - "User.favoriteBadgeIds", "User.favoriteTrophyIds", "User.hiddenCardStats", "User.hiddenTrophyIds", diff --git a/app/db/json-selections.test.ts b/app/db/json-selections.test.ts index 99d161999..8f6b93282 100644 --- a/app/db/json-selections.test.ts +++ b/app/db/json-selections.test.ts @@ -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"), ]); diff --git a/app/db/seed/dev/badges.ts b/app/db/seed/dev/badges.ts index fa2f38335..007597997 100644 --- a/app/db/seed/dev/badges.ts +++ b/app/db/seed/dev/badges.ts @@ -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, + ); +} diff --git a/app/db/seed/dev/users.ts b/app/db/seed/dev/users.ts index a544203a8..cded296e8 100644 --- a/app/db/seed/dev/users.ts +++ b/app/db/seed/dev/users.ts @@ -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 { - // 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 { 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 { 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 { })), }, 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[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" }, ]; } diff --git a/app/db/seed/factories/UserFactory.ts b/app/db/seed/factories/UserFactory.ts index d6753ae71..b1bcf6483 100644 --- a/app/db/seed/factories/UserFactory.ts +++ b/app/db/seed/factories/UserFactory.ts @@ -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[0], "userId">; /** Division the user played their last season in. */ div?: NonNullable; - /** Weapon pool, submitted as the user themselves. */ - weapons?: Parameters[0]["weapons"]; /** User card fields, submitted as the user themselves. */ card?: Partial; - /** 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[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 { .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 { .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 | 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); } diff --git a/app/db/tables-json.ts b/app/db/tables-json.ts index 2afca6f6b..45d1e222c 100644 --- a/app/db/tables-json.ts +++ b/app/db/tables-json.ts @@ -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; diff --git a/app/db/tables.ts b/app/db/tables.ts index 9a213239c..616beaf10 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -947,8 +947,6 @@ export interface User { /** 1 = permabanned, timestamp = ban active till then */ banned: Generated; bannedReason: string | null; - /** Shown on old user profile and Plus Voting */ - bio: string | null; /** Shown on user card */ shortBio: string | null; commissionsOpen: Generated; @@ -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; favoriteTrophyIds: JSONColumnTypeNullable; hiddenTrophyIds: JSONColumnTypeNullable; id: GeneratedAlways; @@ -978,16 +974,12 @@ export interface User { isTournamentOrganizer: Generated; isApiAccesser: Generated; languages: JSONColumnTypeNullable; - motionSens: number | null; pronouns: JSONColumnTypeNullable; patronStartedAt: number | null; patronTier: number | null; patronExpiresAt: number | null; - showDiscordUniqueName: Generated; - stickSens: number | null; twitch: string | null; bsky: string | null; - battlefy: string | null; vc: Generated<"YES" | "NO" | "LISTEN_ONLY">; youtubeId: string | null; mapModePreferences: JSONColumnTypeNullable; @@ -1038,14 +1030,6 @@ export interface UserSearch { customUrl: GeneratedAlways; } -export interface UserWeapon { - createdAt: Generated; - isFavorite: Generated; - 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; diff --git a/app/features/admin/AdminRepository.server.ts b/app/features/admin/AdminRepository.server.ts index caf6c3c2e..3d07f2461 100644 --- a/app/features/admin/AdminRepository.server.ts +++ b/app/features/admin/AdminRepository.server.ts @@ -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) diff --git a/app/features/admin/routes/admin.test.ts b/app/features/admin/routes/admin.test.ts index 0bc3b49d1..372439acc 100644 --- a/app/features/admin/routes/admin.test.ts +++ b/app/features/admin/routes/admin.test.ts @@ -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 }, ]); }); diff --git a/app/features/api-public/routes/tournament.$id.teams.ts b/app/features/api-public/routes/tournament.$id.teams.ts index b48263699..4afe7b94a 100644 --- a/app/features/api-public/routes/tournament.$id.teams.ts +++ b/app/features/api-public/routes/tournament.$id.teams.ts @@ -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` diff --git a/app/features/api-public/routes/user.$identifier.ts b/app/features/api-public/routes/user.$identifier.ts index 837049042..4a2efb008 100644 --- a/app/features/api-public/routes/user.$identifier.ts +++ b/app/features/api-public/routes/user.$identifier.ts @@ -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 }, diff --git a/app/features/api-public/schema.ts b/app/features/api-public/schema.ts index 8adb544c4..fd35c8e61 100644 --- a/app/features/api-public/schema.ts +++ b/app/features/api-public/schema.ts @@ -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" */ diff --git a/app/features/badges/BadgeRepository.server.ts b/app/features/badges/BadgeRepository.server.ts index 2471fda6c..3d7e6ee2c 100644 --- a/app/features/badges/BadgeRepository.server.ts +++ b/app/features/badges/BadgeRepository.server.ts @@ -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) { diff --git a/app/features/info/routes/support.tsx b/app/features/info/routes/support.tsx index 8498e2aa5..5f81e6d12 100644 --- a/app/features/info/routes/support.tsx +++ b/app/features/info/routes/support.tsx @@ -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", diff --git a/app/features/lfg/LFGRepository.server.ts b/app/features/lfg/LFGRepository.server.ts index 11b8fa4ad..b3b1c8906 100644 --- a/app/features/lfg/LFGRepository.server.ts +++ b/app/features/lfg/LFGRepository.server.ts @@ -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"), diff --git a/app/features/lfg/loaders/lfg.new.server.ts b/app/features/lfg/loaders/lfg.new.server.ts index f283a598b..6114233c7 100644 --- a/app/features/lfg/loaders/lfg.new.server.ts +++ b/app/features/lfg/loaders/lfg.new.server.ts @@ -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), diff --git a/app/features/lfg/routes/lfg.new.tsx b/app/features/lfg/routes/lfg.new.tsx index ca9dc526c..f5f5f0ecb 100644 --- a/app/features/lfg/routes/lfg.new.tsx +++ b/app/features/lfg/routes/lfg.new.tsx @@ -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(); return ( @@ -161,8 +160,8 @@ function WeaponPool() { {t("lfg:new.editOn")}{" "} - - {t("lfg:new.weaponPool.userProfile")} + + {t("lfg:new.weaponPool.matchProfile")} diff --git a/app/features/match-profile/MatchProfileRepository.server.ts b/app/features/match-profile/MatchProfileRepository.server.ts index 1624c4876..521168bca 100644 --- a/app/features/match-profile/MatchProfileRepository.server.ts +++ b/app/features/match-profile/MatchProfileRepository.server.ts @@ -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, diff --git a/app/features/plus-voting/PlusVotingRepository.server.test.ts b/app/features/plus-voting/PlusVotingRepository.server.test.ts new file mode 100644 index 000000000..f9acb4e56 --- /dev/null +++ b/app/features/plus-voting/PlusVotingRepository.server.test.ts @@ -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"); + }); +}); diff --git a/app/features/plus-voting/PlusVotingRepository.server.ts b/app/features/plus-voting/PlusVotingRepository.server.ts index 00ba36d2f..b44e1541a 100644 --- a/app/features/plus-voting/PlusVotingRepository.server.ts +++ b/app/features/plus-voting/PlusVotingRepository.server.ts @@ -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(); + const bios = new Map(); + + 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`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; } diff --git a/app/features/plus-voting/routes/plus.voting.tsx b/app/features/plus-voting/routes/plus.voting.tsx index 2d7c94a7b..60fda1390 100644 --- a/app/features/plus-voting/routes/plus.voting.tsx +++ b/app/features/plus-voting/routes/plus.voting.tsx @@ -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) { {currentUser.user.bio ? (

Bio

- {currentUser.user.bio} + {currentUser.user.bio.markdown ? ( + {currentUser.user.bio.text} + ) : ( + currentUser.user.bio.text + )}
) : null} diff --git a/app/features/team/TeamRepository.server.ts b/app/features/team/TeamRepository.server.ts index 9849e35fb..2d1235d56 100644 --- a/app/features/team/TeamRepository.server.ts +++ b/app/features/team/TeamRepository.server.ts @@ -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"), diff --git a/app/features/team/actions/t.$customUrl.edit.server.test.ts b/app/features/team/actions/t.$customUrl.edit.server.test.ts index acc141da5..c01f73342 100644 --- a/app/features/team/actions/t.$customUrl.edit.server.test.ts +++ b/app/features/team/actions/t.$customUrl.edit.server.test.ts @@ -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()); diff --git a/app/features/trophies/TrophyRepository.server.test.ts b/app/features/trophies/TrophyRepository.server.test.ts index dc5befca1..75c784d8e 100644 --- a/app/features/trophies/TrophyRepository.server.test.ts +++ b/app/features/trophies/TrophyRepository.server.test.ts @@ -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", diff --git a/app/features/user-page/UserRepository.server.ts b/app/features/user-page/UserRepository.server.ts index 6754010d0..f36d5648a 100644 --- a/app/features/user-page/UserRepository.server.ts +++ b/app/features/user-page/UserRepository.server.ts @@ -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("XRankPlacement.power").as("power"), - fn.min("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("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, @@ -337,42 +263,37 @@ export async function findStoredWidgetsByUserId( ): Promise> { 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 { - 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 { + 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) => "User.inGameName", "User.tournamentName", "PlusTier.tier as plusTier", - eb - .fn("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[]; - 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`case when "TenStarWeapon"."weaponSplId" is not null then 1 else 0 end`.as( - "isTenStar", - ), - ]) - .where("UserWeaponPool.userId", "=", userId) - .orderBy("UserWeaponPool.sortOrder", "asc") - .execute(); -} diff --git a/app/features/user-page/UserRepository.test.ts b/app/features/user-page/UserRepository.test.ts index 7ef59a793..d5e364d7c 100644 --- a/app/features/user-page/UserRepository.test.ts +++ b/app/features/user-page/UserRepository.test.ts @@ -514,6 +514,43 @@ describe("UserRepository", () => { }); }); + describe("UserRepository.findStoredWidgetsByUserId", () => { + const sixMainWidgets: Parameters[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(); diff --git a/app/features/user-page/actions/u.$identifier.edit-widgets.server.ts b/app/features/user-page/actions/u.$identifier.edit-widgets.server.ts index fda420ed1..c2bdd083b 100644 --- a/app/features/user-page/actions/u.$identifier.edit-widgets.server.ts +++ b/app/features/user-page/actions/u.$identifier.edit-widgets.server.ts @@ -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) { diff --git a/app/features/user-page/actions/u.$identifier.edit.server.ts b/app/features/user-page/actions/u.$identifier.edit.server.ts index 0848cb352..502a65761 100644 --- a/app/features/user-page/actions/u.$identifier.edit.server.ts +++ b/app/features/user-page/actions/u.$identifier.edit.server.ts @@ -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 = diff --git a/app/features/user-page/bio-json.server.test.ts b/app/features/user-page/bio-json.server.test.ts deleted file mode 100644 index 87fbabccb..000000000 --- a/app/features/user-page/bio-json.server.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/app/features/user-page/components/Widget.module.css b/app/features/user-page/components/Widget.module.css index 7cada4cae..f2924d6b9 100644 --- a/app/features/user-page/components/Widget.module.css +++ b/app/features/user-page/components/Widget.module.css @@ -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)); diff --git a/app/features/user-page/components/Widget.tsx b/app/features/user-page/components/Widget.tsx index 71b806f91..aef226bc7 100644 --- a/app/features/user-page/components/Widget.tsx +++ b/app/features/user-page/components/Widget.tsx @@ -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
{widget.data.bio}
; + return widget.data.bio ?
{widget.data.bio}
: null; case "bio-md": - return ( + return widget.data.bio ? (
{widget.data.bio}
- ); + ) : null; case "trophies-owned": - return ; + return widget.data.length === 0 ? null : ( + + ); case "badges-owned": - return ( + return widget.data.length === 0 ? null : ( ); case "badges-authored": - return ( + return widget.data.length === 0 ? null : ( ); case "badges-managed": - return ( + return widget.data.length === 0 ? null : ( ); case "teams": - return ( + return widget.data.length === 0 ? null : ( ({ id: team.id, @@ -115,7 +118,7 @@ export function Widget({ /> ); case "organizations": - return ( + return widget.data.length === 0 ? null : ( ({ id: org.id, @@ -249,7 +252,10 @@ export function Widget({ ); case "sens": - return ; + return typeof widget.data.motionSens !== "number" && + typeof widget.data.stickSens !== "number" ? null : ( + + ); case "art": return widget.data.length === 0 ? null : ( @@ -303,8 +309,12 @@ export function Widget({ } })(); + const renderedContent = content(); + + if (!renderedContent) return null; + return ( -
+

{t(`user:widget.${widget.id}`)}

{widgetLink ? ( @@ -313,7 +323,7 @@ export function Widget({ ) : null}
-
{content()}
+
{renderedContent}
); } @@ -639,10 +649,15 @@ function WeaponPool({ }) { return (
- {weapons.map((weapon) => { + {weapons.map((weapon, i) => { return ( -
- +
+
); })} @@ -679,9 +694,6 @@ function SensWidget({ }) { const { t } = useTranslation(["user"]); - const rawSensToString = (sens: number) => - `${sens > 0 ? "+" : ""}${sens / 10}`; - return (
; case "tier-list": return ; + case "badges-owned": + return ; case "game-badges": return ( @@ -116,6 +122,19 @@ function WidgetFormFields({ widgetId }: { widgetId: string }) { } } +function FavoriteBadgesField() { + const data = useLoaderData(); + const isSupporter = useHasRole("SUPPORTER"); + + return ( + + ); +} + const SENS_OPTIONS = [ -50, -45, -40, -35, -30, -25, -20, -15, -10, -5, 0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, diff --git a/app/features/user-page/core/badge-sorting.server.test.ts b/app/features/user-page/core/badge-sorting.server.test.ts index d8738d047..3e2601610 100644 --- a/app/features/user-page/core/badge-sorting.server.test.ts +++ b/app/features/user-page/core/badge-sorting.server.test.ts @@ -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]); }); }); diff --git a/app/features/user-page/core/badge-sorting.server.ts b/app/features/user-page/core/badge-sorting.server.ts index 5c70533b9..c6b9d3e04 100644 --- a/app/features/user-page/core/badge-sorting.server.ts +++ b/app/features/user-page/core/badge-sorting.server.ts @@ -1,39 +1,32 @@ import { isSupporter } from "~/modules/permissions/utils"; interface SortBadgesByFavoritesArgs { - 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({ favoriteBadgeIds, badges, patronTier, -}: SortBadgesByFavoritesArgs): { - 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 { + 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({ return b.id - a.id; }) as T; - - return { badges: sortedBadges, favoriteBadgeIds: filteredFavoriteIds }; } diff --git a/app/features/user-page/core/widgets/portfolio-loaders.server.ts b/app/features/user-page/core/widgets/portfolio-loaders.server.ts index 24e005e4e..9b971cda6 100644 --- a/app/features/user-page/core/widgets/portfolio-loaders.server.ts +++ b/app/features/user-page/core/widgets/portfolio-loaders.server.ts @@ -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); diff --git a/app/features/user-page/core/widgets/portfolio.test.ts b/app/features/user-page/core/widgets/portfolio.test.ts new file mode 100644 index 000000000..5498ba05e --- /dev/null +++ b/app/features/user-page/core/widgets/portfolio.test.ts @@ -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); + }); +}); diff --git a/app/features/user-page/core/widgets/portfolio.ts b/app/features/user-page/core/widgets/portfolio.ts index aa8b2915d..b11de1684 100644 --- a/app/features/user-page/core/widgets/portfolio.ts +++ b/app/features/user-page/core/widgets/portfolio.ts @@ -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; }): 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) { return def; } diff --git a/app/features/user-page/core/widgets/widget-form-schemas.ts b/app/features/user-page/core/widgets/widget-form-schemas.ts index 45e2435b3..fc1577d3c 100644 --- a/app/features/user-page/core/widgets/widget-form-schemas.ts +++ b/app/features/user-page/core/widgets/widget-form-schemas.ts @@ -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 = { art: artSchema, links: linksSchema, "tier-list": tierListSchema, + "badges-owned": badgesOwnedSchema, "game-badges": gameBadgesSchema, "game-badges-small": gameBadgesSmallSchema, }; diff --git a/app/features/user-page/loaders/u.$identifier.edit-widgets.server.ts b/app/features/user-page/loaders/u.$identifier.edit-widgets.server.ts index e91123257..c8bf7f595 100644 --- a/app/features/user-page/loaders/u.$identifier.edit-widgets.server.ts +++ b/app/features/user-page/loaders/u.$identifier.edit-widgets.server.ts @@ -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 }; }; diff --git a/app/features/user-page/loaders/u.$identifier.edit.server.ts b/app/features/user-page/loaders/u.$identifier.edit.server.ts index 234a41e38..caa9deab4 100644 --- a/app/features/user-page/loaders/u.$identifier.edit.server.ts +++ b/app/features/user-page/loaders/u.$identifier.edit.server.ts @@ -20,9 +20,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, }; }; diff --git a/app/features/user-page/loaders/u.$identifier.index.server.ts b/app/features/user-page/loaders/u.$identifier.index.server.ts index 7972af40a..4fc0899fc 100644 --- a/app/features/user-page/loaders/u.$identifier.index.server.ts +++ b/app/features/user-page/loaders/u.$identifier.index.server.ts @@ -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, }; }; diff --git a/app/features/user-page/loaders/u.$identifier.server.ts b/app/features/user-page/loaders/u.$identifier.server.ts index c6639dd73..8e2dccd48 100644 --- a/app/features/user-page/loaders/u.$identifier.server.ts +++ b/app/features/user-page/loaders/u.$identifier.server.ts @@ -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; -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, }; }; diff --git a/app/features/user-page/routes/u.$identifier.edit-widgets.module.css b/app/features/user-page/routes/u.$identifier.edit-widgets.module.css index 20c8b96fc..8e2f3d9a8 100644 --- a/app/features/user-page/routes/u.$identifier.edit-widgets.module.css +++ b/app/features/user-page/routes/u.$identifier.edit-widgets.module.css @@ -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; +} diff --git a/app/features/user-page/routes/u.$identifier.edit-widgets.tsx b/app/features/user-page/routes/u.$identifier.edit-widgets.tsx index d4498853b..7ae301769 100644 --- a/app/features/user-page/routes/u.$identifier.edit-widgets.tsx +++ b/app/features/user-page/routes/u.$identifier.edit-widgets.tsx @@ -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; + export default function EditWidgetsPage() { const { t } = useTranslation(["user", "common"]); const data = useLoaderData(); 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 >(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 ; + return ( +
+ + +
+ ); } return (
+

{t("user:widgets.editTitle")}

@@ -175,6 +201,7 @@ export default function EditWidgetsPage() { @@ -202,6 +231,8 @@ interface AvailableWidgetsListProps { selectedWidgets: Array; mainWidgets: Array; sideWidgets: Array; + 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 (
@@ -264,15 +296,25 @@ function AvailableWidgetsList({ {t(`user:widget.${widget.id}` as const)} - onAddWidget(widget.id)} - isDisabled={isSelected || isMaxReached} - testId={`add-widget-${widget.id}`} - > - {t("user:widgets.add")} - + {isLocked ? ( + + {t("user:widgets.supporterOnly")} + + ) : ( + onAddWidget(widget.id)} + isDisabled={isSelected || isMaxReached} + testId={`add-widget-${widget.id}`} + > + {t("user:widgets.add")} + + )}
@@ -309,6 +351,7 @@ function AvailableWidgetsList({ interface SelectedWidgetsListProps { mainWidgets: Array; sideWidgets: Array; + 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({ {t("user:widgets.mainSlot")} - - {mainWidgets.length}/{USER.MAX_MAIN_WIDGETS} - +
w.id)}>
@@ -363,9 +409,11 @@ function SelectedWidgetsList({ {t("user:widgets.sideSlot")} - - {sideWidgets.length}/{USER.MAX_SIDE_WIDGETS} - +
w.id)}>
@@ -392,6 +440,29 @@ function SelectedWidgetsList({ ); } +function SlotCount({ + count, + max, + supporterMax, +}: { + count: number; + max: number; + supporterMax: number; +}) { + const { t } = useTranslation(["user"]); + + return ( + + {count}/{max} + {max === supporterMax ? null : ( + + {t("user:widgets.supporterMax", { max: supporterMax })} + + )} + + ); +} + 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")} diff --git a/app/features/user-page/routes/u.$identifier.edit.test.ts b/app/features/user-page/routes/u.$identifier.edit.test.ts index 42bf9c052..1c4548df3 100644 --- a/app/features/user-page/routes/u.$identifier.edit.test.ts +++ b/app/features/user-page/routes/u.$identifier.edit.test.ts @@ -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({ }); 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, }; diff --git a/app/features/user-page/routes/u.$identifier.edit.tsx b/app/features/user-page/routes/u.$identifier.edit.tsx index 802f68837..439d1aea7 100644 --- a/app/features/user-page/routes/u.$identifier.edit.tsx +++ b/app/features/user-page/routes/u.$identifier.edit.tsx @@ -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 ( -
- - {({ FormField }) => ( - <> - - - - - - - - - - {data.user.badges.length >= 2 ? ( - - ) : null} - {isSupporter && data.ownedTrophies.length >= 2 ? ( - - ) : null} - {data.ownedTrophies.length >= 1 ? ( - - ) : null} - - - {data.discordUniqueName ? ( - - ) : null} - {isArtist ? ( - <> - - - - ) : null} - - - - Username, profile picture, YouTube, Bluesky and Twitch accounts - come from your Discord account. See - FAQ for more information. - - - - )} - +
+ +
+ + {({ FormField }) => ( + <> + + + + + + + + {isSupporter && data.ownedTrophies.length >= 2 ? ( + + ) : null} + {data.ownedTrophies.length >= 1 ? ( + + ) : null} + {isArtist ? ( + <> + + + + ) : null} + + + Username, profile picture, YouTube, Bluesky and Twitch + accounts come from your Discord account. See + FAQ for more information. + + + + )} + +
); } @@ -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, - ]; -} diff --git a/app/features/user-page/routes/u.$identifier.index.module.css b/app/features/user-page/routes/u.$identifier.index.module.css index 6aa2e1c71..b8f274f35 100644 --- a/app/features/user-page/routes/u.$identifier.index.module.css +++ b/app/features/user-page/routes/u.$identifier.index.module.css @@ -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; + } } } diff --git a/app/features/user-page/routes/u.$identifier.index.tsx b/app/features/user-page/routes/u.$identifier.index.tsx index 71bcd523a..281b20d2c 100644 --- a/app/features/user-page/routes/u.$identifier.index.tsx +++ b/app/features/user-page/routes/u.$identifier.index.tsx @@ -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(); - - if (data.type === "new") { - return ; - } - return ; -} - -function NewUserInfoPage() { const { t, i18n } = useTranslation(["user"]); const data = useLoaderData(); 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() {
-
- {sideWidgets.map((widget) => ( - - ))} -
- -
- {mainWidgets.map((widget) => ( - - ))} -
- -
+
+
+ {sideWidgets.map((widget) => ( + + ))} +
{mainWidgets.map((widget) => ( ))}
-
- {sideWidgets.map((widget) => ( - - ))} -
); } -export function OldUserInfoPage() { - const data = useLoaderData(); - const [, parentRoute] = useMatches(); - invariant(parentRoute); - const layoutData = parentRoute.loaderData as UserPageLoaderData; - - if (data.type !== "old") { - throw new Error("Expected old user data"); - } - - return ( -
-
-
- - - -
-

- -
{layoutData.user.username}
-
-
- {data.user.country ? ( - - ) : null} -
-

- -
-
- {data.user.twitch ? ( - - ) : null} - {data.user.youtubeId ? ( - - ) : null} - {data.user.battlefy ? ( - - ) : null} - {data.user.bsky ? ( - - ) : null} -
-
-
- -
-
- - - - {data.trophies.length > 0 ? ( - - ) : null} - - {data.user.bio &&
{data.user.bio}
} -
- ); -} - -function TeamInfo() { - const { t } = useTranslation(["team"]); - const data = useLoaderData(); - - if (data.type !== "old") { - throw new Error("Expected old user data"); - } - - if (!data.user.team) return null; - - return ( -
- - {data.user.team.avatarUrl ? ( - - ) : null} -
- {data.user.team.name} - {data.user.team.userTeamCustomRole ? ( -
- {data.user.team.userTeamCustomRole} -
- ) : data.user.team.userTeamRole ? ( -
- {t(`team:roles.${data.user.team.userTeamRole}`)} -
- ) : null} -
- - -
- ); -} - -function SecondaryTeamsPopover() { - const { t } = useTranslation(["team"]); - - const data = useLoaderData(); - - if (data.type !== "old") { - throw new Error("Expected old user data"); - } - - if (data.user.secondaryTeams.length === 0) return null; - - return ( - - - +{data.user.secondaryTeams.length} - - - } - > -
- {data.user.secondaryTeams.map((team) => ( -
- - {team.avatarUrl ? ( - - ) : null} - {team.name} - - {team.userTeamCustomRole ? ( -
- {team.userTeamCustomRole} -
- ) : team.userTeamRole ? ( -
- {t(`team:roles.${team.userTeamRole}`)} -
- ) : null} -
- ))} -
-
- ); -} - -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 ( - - - - ); -} - -function SocialLinkIcon({ type }: Pick) { - switch (type) { - case "twitch": - return ; - case "youtube": - return ; - case "battlefy": - return ; - case "bsky": - return ; - default: - assertUnreachable(type); - } -} - -function ExtraInfos() { - const { t } = useTranslation(["user"]); - const data = useLoaderData(); - - if (data.type !== "old") { - throw new Error("Expected old user data"); - } - - const motionSensText = - typeof data.user.motionSens === "number" - ? `${t("user:motion")} ${rawSensToString(data.user.motionSens)}` - : 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 ( -
-
#{data.user.id}
- {data.user.discordUniqueName && ( -
- - - {" "} - {data.user.discordUniqueName} -
- )} - {data.user.pronouns && ( -
- - {t("user:usesPronouns")} - {" "} - {data.user.pronouns.subject}/{data.user.pronouns.object} -
- )} - {data.user.inGameName && ( -
- {t("user:ign.short")}{" "} - {data.user.inGameName} -
- )} - {typeof data.user.stickSens === "number" && ( -
- {t("user:sens")}{" "} - {[motionSensText, stickSensText].filter(Boolean).join(" / ")} -
- )} - {data.user.plusTier && ( -
- {" "} - {data.user.plusTier} -
- )} -
- ); -} - -function WeaponPool() { - const data = useLoaderData(); - - if (data.type !== "old") { - throw new Error("Expected old user data"); - } - - if (data.user.weapons.length === 0) return null; - - return ( -
- {data.user.weapons.map((weapon, i) => { - return ( -
- -
- ); - })} -
- ); -} - function ProfileSubtitle({ inGameName, pronouns, @@ -554,38 +180,3 @@ function ProfileSubtitle({
); } - -function TopPlacements() { - const data = useLoaderData(); - - if (data.type !== "old") { - throw new Error("Expected old user data"); - } - - if (data.user.topPlacements.length === 0) return null; - - return ( - - {modesShort.map((mode) => { - const placement = data.user.topPlacements.find( - (placement) => placement.mode === mode, - ); - - if (!placement) return null; - - return ( -
- -
- {placement.rank} / {placement.power} -
-
- ); - })} - - ); -} diff --git a/app/features/user-page/routes/u.$identifier.tsx b/app/features/user-page/routes/u.$identifier.tsx index 67cc8afc5..ac6912b77 100644 --- a/app/features/user-page/routes/u.$identifier.tsx +++ b/app/features/user-page/routes/u.$identifier.tsx @@ -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 (
- {isNewUserPage ? null : ( - - - {t("common:header.profile")} - - - {t("user:seasons")} - - {isOwnPage ? ( - - {t("common:actions.edit")} - - ) : null} - {allResultsCount > 0 ? ( - - {t("common:results")} ({allResultsCount}) - - ) : null} - {data.user.buildsCount > 0 || isOwnPage ? ( - - {t("common:pages.builds")} ({data.user.buildsCount}) - - ) : null} - {data.user.vodsCount > 0 || isOwnPage ? ( - - {t("common:pages.vods")} ({data.user.vodsCount}) - - ) : null} - {data.user.artCount > 0 || isOwnPage ? ( - - {t("common:pages.art")} ({data.user.artCount}) - - ) : null} - {isStaff ? ( - - Admin - - ) : null} - - )}
); diff --git a/app/features/user-page/user-page-constants.ts b/app/features/user-page/user-page-constants.ts index 87c65d810..6dd3a6ac8 100644 --- a/app/features/user-page/user-page-constants.ts +++ b/app/features/user-page/user-page-constants.ts @@ -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, }; diff --git a/app/features/user-page/user-page-schemas.ts b/app/features/user-page/user-page-schemas.ts index e95e33156..20d36bbe4 100644 --- a/app/features/user-page/user-page-schemas.ts +++ b/app/features/user-page/user-page-schemas.ts @@ -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()), diff --git a/app/features/user-page/user-page-urls.test.ts b/app/features/user-page/user-page-urls.test.ts new file mode 100644 index 000000000..7da6f79f9 --- /dev/null +++ b/app/features/user-page/user-page-urls.test.ts @@ -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, + ); + }); +}); diff --git a/app/features/user-page/user-page-urls.ts b/app/features/user-page/user-page-urls.ts index 747ed254e..9cb222d8d 100644 --- a/app/features/user-page/user-page-urls.ts +++ b/app/features/user-page/user-page-urls.ts @@ -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}`; +} diff --git a/app/utils/kysely.server.ts b/app/utils/kysely.server.ts index a1fabeca4..8394cf55a 100644 --- a/app/utils/kysely.server.ts +++ b/app/utils/kysely.server.ts @@ -429,26 +429,6 @@ export function matchProfileWeapons(eb: ExpressionBuilder) { ); } -/** User profile weapons (from UserWeapon) with TenStarWeapon join. Correlates on "User"."id". */ -export function userProfileWeapons(eb: ExpressionBuilder) { - 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. diff --git a/changelog/2026-09-05-one-weapon-pool.md b/changelog/2026-09-05-one-weapon-pool.md new file mode 100644 index 000000000..5bc6a0184 --- /dev/null +++ b/changelog/2026-09-05-one-weapon-pool.md @@ -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 diff --git a/changelog/2026-09-05-user-page-canonical-url.md b/changelog/2026-09-05-user-page-canonical-url.md new file mode 100644 index 000000000..c005c3691 --- /dev/null +++ b/changelog/2026-09-05-user-page-canonical-url.md @@ -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 diff --git a/changelog/2026-09-05-widget-profile-for-everyone.md b/changelog/2026-09-05-widget-profile-for-everyone.md new file mode 100644 index 000000000..98f0ec02a --- /dev/null +++ b/changelog/2026-09-05-widget-profile-for-everyone.md @@ -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. \ No newline at end of file diff --git a/e2e/admin.spec.ts b/e2e/admin.spec.ts index 2aa30c63b..cd5bcdb59 100644 --- a/e2e/admin.spec.ts +++ b/e2e/admin.spec.ts @@ -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(); diff --git a/e2e/builds.spec.ts b/e2e/builds.spec.ts index 2803ae83c..e556db8b1 100644 --- a/e2e/builds.spec.ts +++ b/e2e/builds.spec.ts @@ -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"); }); diff --git a/e2e/pages/builds/user-builds-page.ts b/e2e/pages/builds/user-builds-page.ts index 8253cf197..3697ad954 100644 --- a/e2e/pages/builds/user-builds-page.ts +++ b/e2e/pages/builds/user-builds-page.ts @@ -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"), diff --git a/e2e/pages/user/user-edit-profile-page.ts b/e2e/pages/user/user-edit-profile-page.ts index 643d52dec..3cd202324 100644 --- a/e2e/pages/user/user-edit-profile-page.ts +++ b/e2e/pages/user/user-edit-profile-page.ts @@ -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); diff --git a/e2e/pages/user/user-edit-widgets-page.ts b/e2e/pages/user/user-edit-widgets-page.ts index ebe48b0b1..0dbe4a748 100644 --- a/e2e/pages/user/user-edit-widgets-page.ts +++ b/e2e/pages/user/user-edit-widgets-page.ts @@ -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. */ diff --git a/e2e/pages/user/user-page.ts b/e2e/pages/user/user-page.ts index 346354457..2454441b8 100644 --- a/e2e/pages/user/user-page.ts +++ b/e2e/pages/user/user-page.ts @@ -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(); } diff --git a/e2e/team.spec.ts b/e2e/team.spec.ts index 1c9e6cea3..059256861 100644 --- a/e2e/team.spec.ts +++ b/e2e/team.spec.ts @@ -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 ({ diff --git a/e2e/tournament-bracket-multi-stage.spec.ts b/e2e/tournament-bracket-multi-stage.spec.ts index 81aee92d6..c58797657 100644 --- a/e2e/tournament-bracket-multi-stage.spec.ts +++ b/e2e/tournament-bracket-multi-stage.spec.ts @@ -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(), diff --git a/e2e/trophies.spec.ts b/e2e/trophies.spec.ts index 7c4846cbb..6ddd88c49 100644 --- a/e2e/trophies.spec.ts +++ b/e2e/trophies.spec.ts @@ -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); diff --git a/e2e/user-page.spec.ts b/e2e/user-page.spec.ts index bf1126e1e..66498c9db 100644 --- a/e2e/user-page.spec.ts +++ b/e2e/user-page.spec.ts @@ -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}`); + }); }); /** diff --git a/locales/da/common.json b/locales/da/common.json index dccfb24db..a10e6649f 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -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": "", diff --git a/locales/da/forms.json b/locales/da/forms.json index 1f14ed2ae..37167e632 100644 --- a/locales/da/forms.json +++ b/locales/da/forms.json @@ -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": "", diff --git a/locales/da/lfg.json b/locales/da/lfg.json index f14ec0ece..a913dd38e 100644 --- a/locales/da/lfg.json +++ b/locales/da/lfg.json @@ -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" diff --git a/locales/da/user.json b/locales/da/user.json index 22331d83b..4efe88f39 100644 --- a/locales/da/user.json +++ b/locales/da/user.json @@ -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 for yderligere information.", "results.placing": "Placering", "results.team": "Hold", diff --git a/locales/de/common.json b/locales/de/common.json index c29571940..fd1b0d0ec 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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": "", diff --git a/locales/de/forms.json b/locales/de/forms.json index 54ed9016a..49fdc281c 100644 --- a/locales/de/forms.json +++ b/locales/de/forms.json @@ -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": "", diff --git a/locales/de/lfg.json b/locales/de/lfg.json index 0dbdb1439..6b33c7eff 100644 --- a/locales/de/lfg.json +++ b/locales/de/lfg.json @@ -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": "" diff --git a/locales/de/user.json b/locales/de/user.json index 30bd55bce..8c2aff299 100644 --- a/locales/de/user.json +++ b/locales/de/user.json @@ -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.", "results.placing": "Platzierung", "results.team": "Team", diff --git a/locales/en/common.json b/locales/en/common.json index e94209d00..b8a9cd87d 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", diff --git a/locales/en/forms.json b/locales/en/forms.json index 3a57562f6..0d2148df3 100644 --- a/locales/en/forms.json +++ b/locales/en/forms.json @@ -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", diff --git a/locales/en/lfg.json b/locales/en/lfg.json index 360dab290..c73908264 100644 --- a/locales/en/lfg.json +++ b/locales/en/lfg.json @@ -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" diff --git a/locales/en/user.json b/locales/en/user.json index afeaf3920..ede043a0c 100644 --- a/locales/en/user.json +++ b/locales/en/user.json @@ -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 for more information.", "results.placing": "Placing", "results.team": "Team", diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index 31c3fc683..2c183da3a 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -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", diff --git a/locales/es-ES/forms.json b/locales/es-ES/forms.json index 68b29cc43..ea5b90e41 100644 --- a/locales/es-ES/forms.json +++ b/locales/es-ES/forms.json @@ -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", diff --git a/locales/es-ES/lfg.json b/locales/es-ES/lfg.json index 889925839..c3cf825a3 100644 --- a/locales/es-ES/lfg.json +++ b/locales/es-ES/lfg.json @@ -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" diff --git a/locales/es-ES/user.json b/locales/es-ES/user.json index b24cb1d03..e8401a690 100644 --- a/locales/es-ES/user.json +++ b/locales/es-ES/user.json @@ -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 para más información.", "results.placing": "Lugar", "results.team": "Equipo", diff --git a/locales/es-US/common.json b/locales/es-US/common.json index 21911eef9..6a6c52797 100644 --- a/locales/es-US/common.json +++ b/locales/es-US/common.json @@ -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", diff --git a/locales/es-US/forms.json b/locales/es-US/forms.json index 179ba61ea..361f40969 100644 --- a/locales/es-US/forms.json +++ b/locales/es-US/forms.json @@ -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", diff --git a/locales/es-US/lfg.json b/locales/es-US/lfg.json index 027129166..2a4b2f25c 100644 --- a/locales/es-US/lfg.json +++ b/locales/es-US/lfg.json @@ -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" diff --git a/locales/es-US/user.json b/locales/es-US/user.json index 76e1c18e7..7e2c145fd 100644 --- a/locales/es-US/user.json +++ b/locales/es-US/user.json @@ -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 para más información.", "results.placing": "Lugar", "results.team": "Equipo", diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json index e0227dd36..e01113368 100644 --- a/locales/fr-CA/common.json +++ b/locales/fr-CA/common.json @@ -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": "", diff --git a/locales/fr-CA/forms.json b/locales/fr-CA/forms.json index 0a25d36cd..7de8dfabe 100644 --- a/locales/fr-CA/forms.json +++ b/locales/fr-CA/forms.json @@ -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": "", diff --git a/locales/fr-CA/lfg.json b/locales/fr-CA/lfg.json index 0dbdb1439..6b33c7eff 100644 --- a/locales/fr-CA/lfg.json +++ b/locales/fr-CA/lfg.json @@ -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": "" diff --git a/locales/fr-CA/user.json b/locales/fr-CA/user.json index b11b3839f..31c0d5a00 100644 --- a/locales/fr-CA/user.json +++ b/locales/fr-CA/user.json @@ -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 pour plus d'informations.", "results.placing": "Placement", "results.team": "Équipe", diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json index 2e3058164..783ae5c72 100644 --- a/locales/fr-EU/common.json +++ b/locales/fr-EU/common.json @@ -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", diff --git a/locales/fr-EU/forms.json b/locales/fr-EU/forms.json index cf812e592..12c46aa23 100644 --- a/locales/fr-EU/forms.json +++ b/locales/fr-EU/forms.json @@ -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": "", diff --git a/locales/fr-EU/lfg.json b/locales/fr-EU/lfg.json index 3e4e8e883..418903fa0 100644 --- a/locales/fr-EU/lfg.json +++ b/locales/fr-EU/lfg.json @@ -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" diff --git a/locales/fr-EU/user.json b/locales/fr-EU/user.json index e3d9ceee1..7e8b5d80c 100644 --- a/locales/fr-EU/user.json +++ b/locales/fr-EU/user.json @@ -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 pour plus d'informations.", "results.placing": "Placement", "results.team": "Équipe", diff --git a/locales/he/common.json b/locales/he/common.json index 4e9e16c49..367d8f835 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -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": "", diff --git a/locales/he/forms.json b/locales/he/forms.json index fbe2c11a6..0d5fb611c 100644 --- a/locales/he/forms.json +++ b/locales/he/forms.json @@ -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": "", diff --git a/locales/he/lfg.json b/locales/he/lfg.json index 0dbdb1439..6b33c7eff 100644 --- a/locales/he/lfg.json +++ b/locales/he/lfg.json @@ -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": "" diff --git a/locales/he/user.json b/locales/he/user.json index 08fbfad76..bd0033830 100644 --- a/locales/he/user.json +++ b/locales/he/user.json @@ -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>שאלות נפוצות למידע נוסף.", "results.placing": "מיקום", "results.team": "צוות", diff --git a/locales/it/common.json b/locales/it/common.json index c80878306..aa29976b0 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -314,6 +314,10 @@ "support.perk.userShortLink": "Short link per la pagina utente", "support.perk.userShortLink.extra": "Invece di es. sendou.ink/u/sendou puoi anche usare snd.ink/sendou quando linki la tua pagina utente.", "support.perk.customizedColorsUser": "Personalizza colori (pagina utente)", + "support.perk.supporterWidgets": "", + "support.perk.supporterWidgets.extra": "", + "support.perk.moreWidgets": "", + "support.perk.moreWidgets.extra": "", "support.perk.customAvatar": "", "support.perk.customAvatar.extra": "", "support.perk.favoriteBadges": "", diff --git a/locales/it/forms.json b/locales/it/forms.json index 1f6221ffc..964600ab1 100644 --- a/locales/it/forms.json +++ b/locales/it/forms.json @@ -272,28 +272,19 @@ "labels.profileCustomName": "Nome personalizzato", "labels.profileCustomUrl": "URL personalizzato", "labels.inGameName": "Nome nel gioco", - "labels.profileBattlefy": "Nome account Battlefy", - "labels.profileMotionSens": "Sensitività Giroscopio", - "labels.profileStickSens": "Sensitività Joystick Dx", "labels.profileCountry": "Paese/Regione", "labels.profileFavoriteBadges": "", - "labels.profileShowDiscordUniqueName": "Mostra username Discord", "labels.profileCommissionsOpen": "Commissioni aperte", "labels.profileCommissionText": "Info sulle commissioni", - "labels.profileNewProfileEnabled": "", "bottomTexts.profileCustomAvatar": "", "bottomTexts.profileCustomName": "Se mancante, viene usato il tuo nome visualizzato Discord", "bottomTexts.profileCustomUrl": "Per gli iscritti al Patreon (Supporter compreso in su) è disponibile il link corto. Es. invece di sendou.ink/u/sendou, può essere usato snd.ink/sendou.", "bottomTexts.profileInGameName": "", - "bottomTexts.profileBattlefy": "Il nome dell'account Battlefy è usato per il seeding e verifica in alcuni tornei", - "bottomTexts.profileShowDiscordUniqueName": "Mostrare il proprio nome unico Discord pubblicamente?", "bottomTexts.profileCommissionsOpen": "", "bottomTexts.profileCommissionText": "Prezzo, posti liberi o altre info relative al commissionarti", - "bottomTexts.profileNewProfileEnabled": "", "errors.profileCustomUrlStrangeChar": "L'URL personalizzato non può contenere caratteri speciali", "errors.profileCustomUrlNumbers": "L'URL personalizzato non può contenere solo numeri", "errors.profileCustomUrlDuplicate": "L'URL personalizzato è già in uso da un altro utente", - "errors.profileSensBothOrNeither": "La sensibilità del giroscopio non può essere impostata se non hai impostato la sensibilità del joystick destro", "errors.profileInGameName": "", "inGameName.addCharacter": "", "inGameName.categories.symbols": "", diff --git a/locales/it/lfg.json b/locales/it/lfg.json index e82907b8e..65286f2c2 100644 --- a/locales/it/lfg.json +++ b/locales/it/lfg.json @@ -24,7 +24,7 @@ "new.visibility.everyone": "Tutti", "new.editOn": "Modifica sul tuo", "new.weaponPool.header": "Pool di armi", - "new.weaponPool.userProfile": "profilo utente", + "new.weaponPool.matchProfile": "", "new.languages.header": "Lingue", "new.languages.placeholder": "Seleziona quelle che preferisci", "new.languages.sqSettingsPage": "Pagina impostazioni SendouQ" diff --git a/locales/it/user.json b/locales/it/user.json index fa6389595..c5585cd62 100644 --- a/locales/it/user.json +++ b/locales/it/user.json @@ -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": "Sensitività Joystick Dx", "motionSens": "Sensitività Giroscopio", - "motion": "Giroscopio", - "stick": "Joystick", - "sens": "Sens.", - "usesPronouns": "", "discordExplanation": "Username, foto profilo, account YouTube, Bluesky e Twitch vengono dal tuo account Discord. Visita <1>FAQ per ulteriori informazioni.", "results.placing": "Risultato", "results.team": "Team", diff --git a/locales/ja/common.json b/locales/ja/common.json index b0b8aff6a..3d3e67f7e 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -314,6 +314,10 @@ "support.perk.userShortLink": "短いプロファイル URL", "support.perk.userShortLink.extra": "津城は sendou.ink/u/sendou などですが、この特典があると sendou.ink/sendou でも使えるようになります。", "support.perk.customizedColorsUser": "色をカスタマイズする (ユーザーページ)", + "support.perk.supporterWidgets": "", + "support.perk.supporterWidgets.extra": "", + "support.perk.moreWidgets": "", + "support.perk.moreWidgets.extra": "", "support.perk.customAvatar": "カスタムアバター", "support.perk.customAvatar.extra": "Discord アバターではないアバターを使用できます。", "support.perk.favoriteBadges": "プロファイルのバッジ", diff --git a/locales/ja/forms.json b/locales/ja/forms.json index 5ce9768d5..b4e12cbab 100644 --- a/locales/ja/forms.json +++ b/locales/ja/forms.json @@ -272,28 +272,19 @@ "labels.profileCustomName": "カスタム 名前", "labels.profileCustomUrl": "カスタム URL", "labels.inGameName": "ゲーム中の名前", - "labels.profileBattlefy": "Battlefyアカウント名", - "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": "Battlefyのアカウント名は特定のトーナメンでシーディング及びにプレイヤー情報の確認に使用されます。", - "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": "", diff --git a/locales/ja/lfg.json b/locales/ja/lfg.json index 9084a81b3..a076e6206 100644 --- a/locales/ja/lfg.json +++ b/locales/ja/lfg.json @@ -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": "SendouQ 設定" diff --git a/locales/ja/user.json b/locales/ja/user.json index a17c7cc26..55c76dd4e 100644 --- a/locales/ja/user.json +++ b/locales/ja/user.json @@ -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>FAQ をご覧ください。", "results.placing": "順位", "results.team": "チーム", diff --git a/locales/ko/common.json b/locales/ko/common.json index 703710a5e..068f70eb1 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -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": "", diff --git a/locales/ko/forms.json b/locales/ko/forms.json index f958a8968..3e4e85657 100644 --- a/locales/ko/forms.json +++ b/locales/ko/forms.json @@ -272,28 +272,19 @@ "labels.profileCustomName": "", "labels.profileCustomUrl": "", "labels.inGameName": "", - "labels.profileBattlefy": "", - "labels.profileMotionSens": "", - "labels.profileStickSens": "", "labels.profileCountry": "국가/지역", "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": "", "errors.profileCustomUrlNumbers": "", "errors.profileCustomUrlDuplicate": "", - "errors.profileSensBothOrNeither": "", "errors.profileInGameName": "", "inGameName.addCharacter": "", "inGameName.categories.symbols": "", diff --git a/locales/ko/lfg.json b/locales/ko/lfg.json index 0dbdb1439..6b33c7eff 100644 --- a/locales/ko/lfg.json +++ b/locales/ko/lfg.json @@ -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": "" diff --git a/locales/ko/user.json b/locales/ko/user.json index 950571459..b23a78e6f 100644 --- a/locales/ko/user.json +++ b/locales/ko/user.json @@ -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": "", "results.placing": "순위", "results.team": "팀", diff --git a/locales/nl/common.json b/locales/nl/common.json index 14b547137..9a9ee8f7c 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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": "", diff --git a/locales/nl/forms.json b/locales/nl/forms.json index 0ad2bf588..77d94543d 100644 --- a/locales/nl/forms.json +++ b/locales/nl/forms.json @@ -272,28 +272,19 @@ "labels.profileCustomName": "", "labels.profileCustomUrl": "Eigen URL", "labels.inGameName": "In-game naam", - "labels.profileBattlefy": "", - "labels.profileMotionSens": "Bewegingsgevoeligheid", - "labels.profileStickSens": "R-stick gevoeligheid", "labels.profileCountry": "Land/regio", "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": "Een eigen URL mag geen speciale karakters bevatten", "errors.profileCustomUrlNumbers": "Een eigen URL kan niet alleen uit nummers bestaan", "errors.profileCustomUrlDuplicate": "Deze URL is al in gebruik", - "errors.profileSensBothOrNeither": "Bewegingsgevoeligheid kan niet worden ingesteld als er niets voor de R-stick ingevoerd is", "errors.profileInGameName": "", "inGameName.addCharacter": "", "inGameName.categories.symbols": "", diff --git a/locales/nl/lfg.json b/locales/nl/lfg.json index 0dbdb1439..6b33c7eff 100644 --- a/locales/nl/lfg.json +++ b/locales/nl/lfg.json @@ -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": "" diff --git a/locales/nl/user.json b/locales/nl/user.json index 673a77f91..8675460be 100644 --- a/locales/nl/user.json +++ b/locales/nl/user.json @@ -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": "R-stick gevoeligheid", "motionSens": "Bewegingsgevoeligheid", - "motion": "Beweging", - "stick": "Stick", - "sens": "Gevoeligheid", - "usesPronouns": "", "discordExplanation": "", "results.placing": "Plaatsing", "results.team": "Team", diff --git a/locales/pl/common.json b/locales/pl/common.json index 6f8734cd4..b8b1ea727 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -314,6 +314,10 @@ "support.perk.userShortLink": "", "support.perk.userShortLink.extra": "", "support.perk.customizedColorsUser": "Ustaw kolor profilu", + "support.perk.supporterWidgets": "", + "support.perk.supporterWidgets.extra": "", + "support.perk.moreWidgets": "", + "support.perk.moreWidgets.extra": "", "support.perk.customAvatar": "", "support.perk.customAvatar.extra": "", "support.perk.favoriteBadges": "", diff --git a/locales/pl/forms.json b/locales/pl/forms.json index 1eb583b3c..0d37ff976 100644 --- a/locales/pl/forms.json +++ b/locales/pl/forms.json @@ -272,28 +272,19 @@ "labels.profileCustomName": "", "labels.profileCustomUrl": "Niestandardowe URL", "labels.inGameName": "Imię In-game", - "labels.profileBattlefy": "", - "labels.profileMotionSens": "Motion sens", - "labels.profileStickSens": "R-stick sens", "labels.profileCountry": "Kraj/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": "Niestandardowe URL nie może zawierać znaków specjalnych", "errors.profileCustomUrlNumbers": "Niestandardowe URL nie może zawierać tylko liczby", "errors.profileCustomUrlDuplicate": "Te niestandardowe URl jest już przez kogoś zajęte", - "errors.profileSensBothOrNeither": "Motion sens nie może być ustawione jeśli R-stick sens nie jest", "errors.profileInGameName": "", "inGameName.addCharacter": "", "inGameName.categories.symbols": "", diff --git a/locales/pl/lfg.json b/locales/pl/lfg.json index 0dbdb1439..6b33c7eff 100644 --- a/locales/pl/lfg.json +++ b/locales/pl/lfg.json @@ -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": "" diff --git a/locales/pl/user.json b/locales/pl/user.json index f2e9b6825..62cf59c25 100644 --- a/locales/pl/user.json +++ b/locales/pl/user.json @@ -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": "R-stick sens", "motionSens": "Motion sens", - "motion": "Motion", - "stick": "Stick", - "sens": "Sens", - "usesPronouns": "", "discordExplanation": "Nazwa, profilowe oraz połączone konta brane są z konta Discord. Zobacz <1>FAQ by dowiedzieć się więcej.", "results.placing": "Placing", "results.team": "Drużyna", diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index a28b34498..9bf479971 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -314,6 +314,10 @@ "support.perk.userShortLink": "", "support.perk.userShortLink.extra": "", "support.perk.customizedColorsUser": "Personalizar cores (página do usuário)", + "support.perk.supporterWidgets": "", + "support.perk.supporterWidgets.extra": "", + "support.perk.moreWidgets": "", + "support.perk.moreWidgets.extra": "", "support.perk.customAvatar": "", "support.perk.customAvatar.extra": "", "support.perk.favoriteBadges": "", diff --git a/locales/pt-BR/forms.json b/locales/pt-BR/forms.json index 309616201..42c844d00 100644 --- a/locales/pt-BR/forms.json +++ b/locales/pt-BR/forms.json @@ -272,28 +272,19 @@ "labels.profileCustomName": "", "labels.profileCustomUrl": "URL personalizado", "labels.inGameName": "Nome no jogo", - "labels.profileBattlefy": "", - "labels.profileMotionSens": "Sensibilidade do Controle de Movimento (Giroscópio)", - "labels.profileStickSens": "Sensibilidade do Analógico Direito", "labels.profileCountry": "País/Região", "labels.profileFavoriteBadges": "", - "labels.profileShowDiscordUniqueName": "Mostrar nome de usuário Discord", "labels.profileCommissionsOpen": "Comissões abertas", "labels.profileCommissionText": "Info sobre comissões", - "labels.profileNewProfileEnabled": "", "bottomTexts.profileCustomAvatar": "", "bottomTexts.profileCustomName": "", "bottomTexts.profileCustomUrl": "", "bottomTexts.profileInGameName": "", - "bottomTexts.profileBattlefy": "", - "bottomTexts.profileShowDiscordUniqueName": "Deixe ativado para mostrar seu nome de usuário único do Discord publicamente.", "bottomTexts.profileCommissionsOpen": "", "bottomTexts.profileCommissionText": "Preço, vagas abertas ou qualquer outra informação relacionada ao processo de fazer um pedido para você.", - "bottomTexts.profileNewProfileEnabled": "", "errors.profileCustomUrlStrangeChar": "URL personalizado não pode conter caracteres especiais", "errors.profileCustomUrlNumbers": "URL personalizado não pode conter apenas números", "errors.profileCustomUrlDuplicate": "Alguém já está usando esse URL personalizado", - "errors.profileSensBothOrNeither": "A sensibilidade de Movimento não pode ser definida se a sensibilidade do Analógico Direito não está", "errors.profileInGameName": "", "inGameName.addCharacter": "", "inGameName.categories.symbols": "", diff --git a/locales/pt-BR/lfg.json b/locales/pt-BR/lfg.json index ad1a11a21..46b2836df 100644 --- a/locales/pt-BR/lfg.json +++ b/locales/pt-BR/lfg.json @@ -24,7 +24,7 @@ "new.visibility.everyone": "Todos", "new.editOn": "Editar no(a) seu/sua", "new.weaponPool.header": "Pool de armas", - "new.weaponPool.userProfile": "perfil de usuário", + "new.weaponPool.matchProfile": "", "new.languages.header": "Línguas", "new.languages.placeholder": "Escolha todos que se aplicam", "new.languages.sqSettingsPage": "página de configurações do SendouQ" diff --git a/locales/pt-BR/user.json b/locales/pt-BR/user.json index a55509021..53c307289 100644 --- a/locales/pt-BR/user.json +++ b/locales/pt-BR/user.json @@ -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": "Sensibilidade do Analógico Direito", "motionSens": "Sensibilidade do Controle de Movimento (Giroscópio)", - "motion": "Movimento (Giroscópio)", - "stick": "Analógico", - "sens": "Sens", - "usesPronouns": "", "discordExplanation": "Nome de usuário, foto de perfil, conta do YouTube, Bluesky e Twitch vêm da sua conta do Discord. Veja o <1>Perguntas Frequentes para mais informações.", "results.placing": "Classificação", "results.team": "Time", diff --git a/locales/ru/common.json b/locales/ru/common.json index 808962b79..bc7c2b346 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -314,6 +314,10 @@ "support.perk.userShortLink": "Короткая ссылка на пользовательскую страницу", "support.perk.userShortLink.extra": "Например, вместо sendou.ink/u/sendou вы можете использовать snd.ink/sendou в качестве ссылки на ваш профиль.", "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": "Выбор наград на первой странице", diff --git a/locales/ru/forms.json b/locales/ru/forms.json index a4da760d1..b71e67ba7 100644 --- a/locales/ru/forms.json +++ b/locales/ru/forms.json @@ -272,28 +272,19 @@ "labels.profileCustomName": "Пользовательское имя", "labels.profileCustomUrl": "Пользовательский URL", "labels.inGameName": "Внутриигровое имя", - "labels.profileBattlefy": "Аккаунт Battlefy", - "labels.profileMotionSens": "Чувствительность наклона", - "labels.profileStickSens": "Чувствительность стика", "labels.profileCountry": "Страна/Регион", "labels.profileFavoriteBadges": "", - "labels.profileShowDiscordUniqueName": "Показать пользовательское имя Discord", "labels.profileCommissionsOpen": "Коммишены открыты", "labels.profileCommissionText": "Информация о коммишенах", - "labels.profileNewProfileEnabled": "", "bottomTexts.profileCustomAvatar": "", "bottomTexts.profileCustomName": "Если пользовательское имя отсутствует, то будет использовано ваше имя в Discord", "bottomTexts.profileCustomUrl": "Для меценатов (Supporter и выше) доступна короткая ссылка. Например, вместо sendou.ink/u/sendou может быть использована сссылка snd.ink/sendou.", "bottomTexts.profileInGameName": "", - "bottomTexts.profileBattlefy": "Имя на Battlefy может быть использовано для посева и верификации в некоторых турнирах", - "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": "", diff --git a/locales/ru/lfg.json b/locales/ru/lfg.json index 985882b6d..0434de60a 100644 --- a/locales/ru/lfg.json +++ b/locales/ru/lfg.json @@ -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": "Страница настроек SendouQ" diff --git a/locales/ru/user.json b/locales/ru/user.json index b3ab9faac..2c4c3d547 100644 --- a/locales/ru/user.json +++ b/locales/ru/user.json @@ -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>FAQ для дополнительной информации.", "results.placing": "Место", "results.team": "Команда", diff --git a/locales/zh/common.json b/locales/zh/common.json index 9ba52b889..9ad5fd42e 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -314,6 +314,10 @@ "support.perk.userShortLink": "个人主页短链接", "support.perk.userShortLink.extra": "在分享您的个人主页链接时,除了使用诸如 sendou.ink/u/sendou 的常规链接外,您还可以使用 snd.ink/sendou。", "support.perk.customizedColorsUser": "自定义颜色(个人主页)", + "support.perk.supporterWidgets": "", + "support.perk.supporterWidgets.extra": "", + "support.perk.moreWidgets": "", + "support.perk.moreWidgets.extra": "", "support.perk.customAvatar": "自定义头像", "support.perk.customAvatar.extra": "上传并使用自定义头像,以替代您的 Discord 头像。", "support.perk.favoriteBadges": "设置个人资料首页徽章", diff --git a/locales/zh/forms.json b/locales/zh/forms.json index da859f77f..f293cc720 100644 --- a/locales/zh/forms.json +++ b/locales/zh/forms.json @@ -272,28 +272,19 @@ "labels.profileCustomName": "自定义昵称", "labels.profileCustomUrl": "自定义 URL", "labels.inGameName": "游戏内昵称", - "labels.profileBattlefy": "Battlefy 用户名", - "labels.profileMotionSens": "陀螺仪操作灵敏度", - "labels.profileStickSens": "右摇杆操作灵敏度", "labels.profileCountry": "国家/地区", "labels.profileFavoriteBadges": "喜爱的徽章", - "labels.profileShowDiscordUniqueName": "显示 Discord 用户名", "labels.profileCommissionsOpen": "委托开放中", "labels.profileCommissionText": "委托信息", - "labels.profileNewProfileEnabled": "新个人资料页", "bottomTexts.profileCustomAvatar": "赞助者(Supporter 及以上级别)可以上传自定义图片来替代 Discord 头像", "bottomTexts.profileCustomName": "如果留空,系统将直接显示您的 Discord 用户名", "bottomTexts.profileCustomUrl": "赞助者(Supporter 及以上级别)可以使用短链接。例如:您可以使用 snd.ink/sendou 来替代 sendou.ink/u/sendou。", "bottomTexts.profileInGameName": "格式:游戏内昵称#数字编号(例如:Player#1234)", - "bottomTexts.profileBattlefy": "在部分赛事中会被用于种子排名和身份验证", - "bottomTexts.profileShowDiscordUniqueName": "在个人资料上公开显示您的 Discord 用户名", "bottomTexts.profileCommissionsOpen": "委托开放状态将在一个月后自动关闭", "bottomTexts.profileCommissionText": "价格、剩余名额或其他约稿相关信息", - "bottomTexts.profileNewProfileEnabled": "启用基于小组件的全新个人主页(仅限赞助者可用)", "errors.profileCustomUrlStrangeChar": "自定义 URL 只能包含字母、数字、连字符 (-) 和下划线 (_)", "errors.profileCustomUrlNumbers": "自定义 URL 不能只由纯数字组成", "errors.profileCustomUrlDuplicate": "该自定义 URL 已被其他人使用", - "errors.profileSensBothOrNeither": "如果未设置右摇杆操作灵敏度,则无法单独设置陀螺仪操作灵敏度", "errors.profileInGameName": "必须符合此格式:名称#数字编号(1-10 位字符,紧跟 #,再加 4-5 位英文 / 数字)", "inGameName.addCharacter": "添加特殊字符", "inGameName.categories.symbols": "符号", diff --git a/locales/zh/lfg.json b/locales/zh/lfg.json index dacdbde96..c47d4177d 100644 --- a/locales/zh/lfg.json +++ b/locales/zh/lfg.json @@ -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": "SendouQ 设置页面" diff --git a/locales/zh/user.json b/locales/zh/user.json index e1655f52a..23eb80384 100644 --- a/locales/zh/user.json +++ b/locales/zh/user.json @@ -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>常见问题与解答。", "results.placing": "排名", "results.team": "队伍", diff --git a/migrations/20260905110355-default-profile-widgets.ts b/migrations/20260905110355-default-profile-widgets.ts new file mode 100644 index 000000000..cea373b93 --- /dev/null +++ b/migrations/20260905110355-default-profile-widgets.ts @@ -0,0 +1,129 @@ +import { type Kysely, sql } from "kysely"; + +/** + * The profile page is widget based for everyone from now on, and bio, sensitivity & + * favorite badges live in widget settings rather than in their own `User` columns. + * Users who have any of those saved get the default layout written out with the values + * carried over, so their profile keeps showing what it showed before. + * + * Users without any of them keep no rows of their own: they render the + * default layout, which stays in sync as the default changes. Users who have already + * picked their widgets are left alone. + * + * Once the values are copied over, the columns they came from go, as does the + * preference that used to gate the widget profile. The battlefy account name goes + * with them, as it is no longer collected or exposed anywhere. The profile weapon + * pool goes too: the match profile's pool is the only weapon pool from now on. + * + * The opt-in for showing the Discord username goes as well, the verified social links + * widget always showing it from now on. Anyone who had opted out loses that widget, + * migrated or already customized, so the change can't expose a username that used to + * be hidden. + */ +export async function up(db: Kysely): Promise { + await sql` + with "eligible" as ( + select + "User"."id", + "User"."bio", + "User"."motionSens", + "User"."stickSens", + "User"."favoriteBadgeIds", + "User"."discordUniqueName", + "User"."showDiscordUniqueName" + from "User" + where ( + ("User"."bio" is not null and "User"."bio" != '') + or "User"."motionSens" is not null + or "User"."stickSens" is not null + or "User"."favoriteBadgeIds" is not null + ) + and not exists ( + select 1 from "UserWidget" where "UserWidget"."userId" = "User"."id" + ) + ), + "presetWidget" as ( + select 0 as "index", json_object('id', 'weapon-pool') as "widget" + union all + select 1, json_object('id', 'x-rank-peaks', 'settings', json_object('division', 'both')) + union all + select 2, json_object('id', 'badges-owned') + union all + select 4, json_object('id', 'teams') + union all + select 7, json_object('id', 'join-date') + ) + insert into "UserWidget" ("userId", "index", "widget") + select "eligible"."id", "presetWidget"."index", "presetWidget"."widget" + from "eligible", "presetWidget" + union all + select + "eligible"."id", + 3, + json_object('id', 'bio', 'settings', json_object('bio', coalesce("eligible"."bio", ''))) + from "eligible" + union all + select + "eligible"."id", + 6, + json_object( + 'id', 'sens', + 'settings', json_object( + 'controller', 's2-pro-con', + 'motionSens', "eligible"."motionSens", + 'stickSens', "eligible"."stickSens" + ) + ) + from "eligible" + union all + select "eligible"."id", 5, json_object('id', 'social-links') + from "eligible" + where "eligible"."discordUniqueName" is null + or "eligible"."showDiscordUniqueName" = 1 + `.execute(db); + + await sql` + delete from "UserWidget" + where json_extract("widget", '$.id') = 'social-links' + and exists ( + select 1 from "User" + where "User"."id" = "UserWidget"."userId" + and "User"."discordUniqueName" is not null + and "User"."showDiscordUniqueName" = 0 + ) + `.execute(db); + + await sql` + update "UserWidget" + set "widget" = json_object( + 'id', 'badges-owned', + 'settings', json_object( + 'favoriteBadgeIds', + json(coalesce( + (select "User"."favoriteBadgeIds" from "User" where "User"."id" = "UserWidget"."userId"), + '[]' + )) + ) + ) + where json_extract("widget", '$.id') = 'badges-owned' + `.execute(db); + + await sql` + update "User" + set "preferences" = json_remove("preferences", '$.newProfileEnabled') + where json_extract("preferences", '$.newProfileEnabled') is not null + `.execute(db); + + for (const column of [ + "bio", + "motionSens", + "stickSens", + "battlefy", + "showDiscordUniqueName", + "favoriteBadgeIds", + ]) { + await db.schema.alterTable("User").dropColumn(column).execute(); + } + + await db.schema.dropTable("UserWeapon").execute(); +} diff --git a/scripts/benchmark-db/cases.ts b/scripts/benchmark-db/cases.ts index 76cb051c7..a5caa6100 100644 --- a/scripts/benchmark-db/cases.ts +++ b/scripts/benchmark-db/cases.ts @@ -221,7 +221,7 @@ export function buildCases(fx: Fixtures): { BadgeRepository.findManagedByUserId(userId), ); add("BadgeRepository.findByOwnerUserId", fx.badgeOwnerUserId, (userId) => - BadgeRepository.findByOwnerUserId(userId), + BadgeRepository.findByOwnerUserId(userId, []), ); add("BadgeRepository.findByAuthorUserId", fx.badgeAuthorId, (userId) => BadgeRepository.findByAuthorUserId(userId), @@ -440,6 +440,9 @@ export function buildCases(fx: Fixtures): { add("MatchProfileRepository.findSettingsByUserId", fx.heavyUser, (user) => MatchProfileRepository.findSettingsByUserId(user.id), ); + add("MatchProfileRepository.findWeaponPoolByUserId", fx.heavyUser, (user) => + MatchProfileRepository.findWeaponPoolByUserId(user.id), + ); add("SkillRepository.findCurrentUserSkills", fx.skillBatch, (skillBatch) => SkillRepository.findCurrentUserSkills({ @@ -1327,20 +1330,11 @@ export function buildCases(fx: Fixtures): { add("UserRepository.findProfileByIdentifier", fx.heavyUser, (user) => UserRepository.findProfileByIdentifier(user.identifier), ); - add("UserRepository.findOwnedBadgesByUserId", fx.badgeOwnerUserId, (userId) => - UserRepository.findOwnedBadgesByUserId(userId), - ); - add("UserRepository.findEnabledWidgetsByIdentifier", fx.heavyUser, (user) => - UserRepository.findEnabledWidgetsByIdentifier(user.identifier), - ); - add("UserRepository.findPreferencesByUserId", fx.heavyUser, (user) => - UserRepository.findPreferencesByUserId(user.id), - ); add("UserRepository.findStoredWidgetsByUserId", fx.heavyUser, (user) => UserRepository.findStoredWidgetsByUserId(user.id), ); add("UserRepository.findWidgetsByUserId", fx.heavyUser, (user) => - UserRepository.findWidgetsByUserId(user.identifier), + UserRepository.findWidgetsByUserId(user.id), ); add("UserRepository.findByCustomUrl", fx.userCustomUrl, (customUrl) => UserRepository.findByCustomUrl(customUrl), @@ -1409,9 +1403,6 @@ export function buildCases(fx: Fixtures): { (twitchUsernames) => UserRepository.findIdsByTwitchUsernames(twitchUsernames), ); - add("UserRepository.findWeaponPoolByUserId", fx.heavyUser, (user) => - UserRepository.findWeaponPoolByUserId(user.id), - ); add("VodRepository.findByUserId", fx.vod, (vod) => VodRepository.findByUserId(vod.userId), diff --git a/scripts/delete-user.ts b/scripts/delete-user.ts index 682844c34..92ab78a8c 100644 --- a/scripts/delete-user.ts +++ b/scripts/delete-user.ts @@ -17,7 +17,6 @@ invariant(user, `user with discord id ${discordId} not found`); const userId = user.id; await db.deleteFrom("Build").where("ownerId", "=", userId).execute(); -await db.deleteFrom("UserWeapon").where("userId", "=", userId).execute(); await db.deleteFrom("User").where("id", "=", userId).execute(); logger.info(`Deleted user with discord id: ${discordId}`);