diff --git a/app/components/DotPagination.module.css b/app/components/DotPagination.module.css
new file mode 100644
index 000000000..b07544875
--- /dev/null
+++ b/app/components/DotPagination.module.css
@@ -0,0 +1,27 @@
+.pagination {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--s-3);
+ justify-content: center;
+ align-items: center;
+ max-width: 20rem;
+ margin: 0 auto;
+ margin-top: var(--s-2);
+}
+
+.button {
+ background-color: var(--color-bg);
+ border-radius: 100%;
+ padding: var(--s-1);
+ height: 24px;
+ width: 24px;
+ border: var(--border-style);
+ font-size: var(--font-xs);
+ color: var(--color-text-high);
+}
+
+.buttonActive {
+ color: var(--color-text-accent);
+ background-color: var(--color-bg-high);
+ border-color: var(--color-border-high);
+}
diff --git a/app/components/DotPagination.tsx b/app/components/DotPagination.tsx
new file mode 100644
index 000000000..9cc4563a8
--- /dev/null
+++ b/app/components/DotPagination.tsx
@@ -0,0 +1,38 @@
+import { clsx } from "clsx";
+import { SendouButton } from "~/components/elements/Button";
+import styles from "./DotPagination.module.css";
+
+export function DotPagination({
+ pagesCount,
+ currentPage,
+ setPage,
+ ariaLabelPrefix,
+ "data-testid": testId,
+ className,
+}: {
+ pagesCount: number;
+ currentPage: number;
+ setPage: (page: number) => void;
+ ariaLabelPrefix: string;
+ "data-testid"?: string;
+ className?: string;
+}) {
+ return (
+
= {
9: styles.tierC,
};
+const POLISHED_TIERS = [1, 2, 3];
+
export function TierPill({
tier,
isTentative = false,
@@ -29,7 +31,9 @@ export function TierPill({
return (
["results"][number],
+ { type: "organization" }
+>;
+
+interface OrganizationSearchProps
+ extends Omit, "children" | "onChange"> {
+ name?: string;
+ label?: string;
+ bottomText?: string;
+ errorText?: string;
+ initialOrganizationId?: number;
+ onChange?: (organization: OrganizationSearchResult | null) => void;
+}
+
+export const OrganizationSearch = React.forwardRef(function OrganizationSearch<
+ T extends object,
+>(
+ {
+ name,
+ label,
+ bottomText,
+ errorText,
+ initialOrganizationId,
+ onChange,
+ ...rest
+ }: OrganizationSearchProps,
+ ref?: React.Ref,
+) {
+ const initialOrganization = useInitialOrganization(initialOrganizationId);
+
+ const search = useEntitySearch({
+ buildUrl: (query) => `/search?q=${query}&type=organizations&limit=6`,
+ parseResults: (data, query) =>
+ parseOrganizationResults(data, query, initialOrganization),
+ initialItem: initialOrganization,
+ initialSelectedId: initialOrganizationId,
+ onChange,
+ });
+
+ return (
+ }
+ />
+ );
+});
+
+function parseOrganizationResults(
+ data: unknown,
+ query: string,
+ initialOrganization?: OrganizationSearchResult,
+): OrganizationSearchResult[] | null {
+ const searchData = data as SearchLoaderData;
+ if (!searchData || searchData.query !== query) return null;
+ return searchData.results
+ .filter(
+ (result): result is OrganizationSearchResult =>
+ result.type === "organization",
+ )
+ .filter((org) => org.id !== initialOrganization?.id);
+}
+
+function useInitialOrganization(initialOrganizationId?: number) {
+ const fetcher = useFetcher();
+
+ React.useEffect(() => {
+ if (!initialOrganizationId || fetcher.state !== "idle" || fetcher.data) {
+ return;
+ }
+ fetcher.load(
+ `/search?q=${initialOrganizationId}&type=organizations&limit=1`,
+ );
+ }, [initialOrganizationId, fetcher]);
+
+ return fetcher.data?.results.find(
+ (result): result is OrganizationSearchResult =>
+ result.type === "organization",
+ );
+}
+
+function OrganizationItem({ item }: { item: OrganizationSearchResult }) {
+ return (
+
+ clsx(searchSelectStyles.item, {
+ [selectStyles.itemFocused]: isFocused,
+ [selectStyles.itemSelected]: isSelected,
+ })
+ }
+ data-testid="organization-search-item"
+ >
+
+ {item.name}
+
+ /{item.slug}
+
+
+
+ );
+}
diff --git a/app/components/elements/SearchSelect.tsx b/app/components/elements/SearchSelect.tsx
index b152ceee8..bbd1213c1 100644
--- a/app/components/elements/SearchSelect.tsx
+++ b/app/components/elements/SearchSelect.tsx
@@ -22,6 +22,10 @@ import selectStyles from "./Select.module.css";
import type { EntitySearch } from "./useEntitySearch";
const PLACEHOLDER_TEXTS = {
+ organizationSearch: {
+ placeholder: "common:forms.organizationSearch.placeholder",
+ noResults: "common:forms.organizationSearch.noResults",
+ },
teamSearch: {
placeholder: "common:forms.teamSearch.placeholder",
noResults: "common:forms.teamSearch.noResults",
diff --git a/app/components/layout/AnythingAdder.tsx b/app/components/layout/AnythingAdder.tsx
index 4a1861a7a..179bf182e 100644
--- a/app/components/layout/AnythingAdder.tsx
+++ b/app/components/layout/AnythingAdder.tsx
@@ -1,10 +1,12 @@
import { Plus } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useUser } from "~/features/auth/core/user";
+import { canAccessTrophies } from "~/features/trophies/trophies-utils";
import {
CALENDAR_NEW_PAGE,
lfgNewPostPage,
NEW_TEAM_PAGE,
+ NEW_TROPHY_PAGE,
navIconUrl,
newArtPage,
newAssociationsPage,
@@ -97,6 +99,14 @@ export function AnythingAdder({ compact }: { compact?: boolean }) {
imagePath: navIconUrl("plus"),
href: plusSuggestionsNewPage(),
},
+ canAccessTrophies(user)
+ ? {
+ id: "trophy",
+ children: t("header.adder.trophy"),
+ imagePath: navIconUrl("trophies"),
+ href: NEW_TROPHY_PAGE,
+ }
+ : null,
].filter((item) => item !== null);
return (
diff --git a/app/components/layout/TopNavMenus.tsx b/app/components/layout/TopNavMenus.tsx
index 1e0c60101..dd322460b 100644
--- a/app/components/layout/TopNavMenus.tsx
+++ b/app/components/layout/TopNavMenus.tsx
@@ -7,6 +7,7 @@ import { Config } from "~/config";
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import { useUser } from "~/features/auth/core/user";
+import { canAccessTrophies } from "~/features/trophies/trophies-utils";
import {
impersonateUrl,
navIconUrl,
@@ -22,6 +23,10 @@ const DEV_IMPERSONATE_ITEMS = [
{ name: "Logged out", icon: "log_in", action: STOP_IMPERSONATING_URL },
] as const;
+const DEV_LINK_ITEMS = [
+ { name: "Components", icon: "settings", url: "/components" },
+] as const;
+
const NAV_CATEGORIES = [
{
name: "play",
@@ -61,7 +66,7 @@ const NAV_CATEGORIES = [
{ name: "art", url: "art" },
{ name: "articles", url: "a" },
{ name: "vods", url: "vods" },
- { name: "badges", url: "badges" },
+ { name: "trophies", url: "trophies" },
{ name: "links", url: "links" },
{ name: "plus", url: "plus/suggestions" },
],
@@ -125,6 +130,25 @@ function DevMenu() {
))}
+ {DEV_LINK_ITEMS.map((item) => (
+ {
+ setIsOpen(false);
+ setIsPreviewSuppressed(true);
+ }}
+ >
+
+ {item.name}
+
+ ))}
{!isOpen && !isPreviewSuppressed ? (
@@ -149,6 +173,19 @@ function DevMenu() {
))}
+ {DEV_LINK_ITEMS.map((item) => (
+ setIsPreviewSuppressed(true)}
+ >
+
+
+ ))}
) : null}
@@ -167,9 +204,11 @@ function CategoryMenu({
const isStaff = user?.roles.includes("STAFF") ?? false;
const showStaffOnly = isStaff || process.env.NODE_ENV === "development";
- const visibleItems = category.items.filter(
- (item) => !("staffOnly" in item) || showStaffOnly,
- );
+ const visibleItems = category.items.filter((item) => {
+ if ("staffOnly" in item && !showStaffOnly) return false;
+ if (item.name === "trophies" && !canAccessTrophies(user)) return false;
+ return true;
+ });
return (
diff --git a/app/components/layout/nav-items.ts b/app/components/layout/nav-items.ts
index 5f88b604d..fee7c175e 100644
--- a/app/components/layout/nav-items.ts
+++ b/app/components/layout/nav-items.ts
@@ -59,8 +59,8 @@ export const navItems = [
prefetch: false,
},
{
- name: "badges",
- url: "badges",
+ name: "trophies",
+ url: "trophies",
prefetch: false,
},
{
diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts
index 38af3c8e4..d5211e0d4 100644
--- a/app/db/seed/index.ts
+++ b/app/db/seed/index.ts
@@ -34,10 +34,17 @@ import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"
import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
import { PRESET_COLORS } from "~/features/tier-list-maker/tier-list-maker-constants";
+import * as XRankPlacementRepository from "~/features/top-search/XRankPlacementRepository.server";
+import type { TournamentTierNumber } from "~/features/tournament/core/tiering";
import type { TournamentMapPickingStyle } from "~/features/tournament/tournament-constants";
import { clearAllTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server";
import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server";
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
+import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
+import {
+ SUPPORTER_TROPHY_CODE,
+ XP_TROPHY_CODE_PREFIX,
+} from "~/features/trophies/trophies-constants";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { USER_REPORT_CATEGORIES } from "~/features/user-report/user-report-constants";
import * as VodRepository from "~/features/vods/VodRepository.server";
@@ -95,6 +102,7 @@ import {
STAFF_TEST_ID,
} from "./constants";
import placements from "./placements.json";
+import trophies from "./trophies.json";
const SENDOUQ_DEFAULT_MAPS: Record<
ModeShort,
@@ -254,6 +262,13 @@ const basicSeeds = (variation?: SeedVariation | null) => [
splatoonRotations,
variation === "FINALIZED_BRACKET" ? finalizedBracket : undefined,
variation === "AB_RR" ? abDivisionsTournament : undefined,
+ trophiesToDb,
+ trophyOwners,
+ trophyWinsTournament,
+ upcomingTrophyTournament,
+ pendingTrophiesToDb,
+ assignTrophyToTournament,
+ specialTrophies,
];
export async function seed(variation?: SeedVariation | null) {
@@ -606,6 +621,11 @@ async function wipeDB() {
"ModNote",
"Friendship",
"FriendRequest",
+ "PendingTrophyApproval",
+ "PendingTrophy",
+ "TrophyOwner",
+ "SpecialTrophyOwner",
+ "Trophy",
"User",
"PlusSuggestion",
"PlusVote",
@@ -1184,6 +1204,548 @@ async function badgeManagers() {
}
}
+async function trophiesToDb() {
+ for (const [name, model] of Object.entries(trophies)) {
+ await db
+ .insertInto("Trophy")
+ .values({
+ name,
+ model,
+ organizationId: 1,
+ creatorId: ADMIN_ID,
+ managerId: NZAP_TEST_ID,
+ })
+ .execute();
+ }
+}
+
+async function trophyOwners() {
+ const trophyIds = (await db.selectFrom("Trophy").select("id").execute()).map(
+ (row) => row.id,
+ );
+
+ const tournamentIds = (
+ await db.selectFrom("Tournament").select("id").execute()
+ ).map((row) => row.id);
+
+ let userIds = (
+ await db
+ .selectFrom("User")
+ .select("id")
+ .where("id", "not in", [NZAP_TEST_ID, ADMIN_ID])
+ .execute()
+ ).map((row) => row.id);
+
+ const randomTier = () =>
+ faker.helpers.maybe(() => faker.number.int({ min: 1, max: 9 }), {
+ probability: 0.85,
+ }) ?? null;
+
+ const usedCombinations = new Set();
+ const insertOwner = async (
+ trophyId: number,
+ userId: number,
+ tournamentId: number,
+ ) => {
+ const key = `${trophyId}-${userId}-${tournamentId}`;
+ if (usedCombinations.has(key)) return;
+ usedCombinations.add(key);
+ await db
+ .insertInto("TrophyOwner")
+ .values({
+ trophyId,
+ userId,
+ tournamentId,
+ tier: randomTier(),
+ })
+ .execute();
+ };
+
+ for (const trophyId of trophyIds) {
+ userIds = faker.helpers.shuffle(userIds);
+ const ownerCount = faker.number.int({ min: 1, max: 8 });
+
+ for (let i = 0; i < ownerCount; i++) {
+ const userId = userIds.shift()!;
+ const copies = faker.number.int({ min: 1, max: 3 });
+ const shuffledTournaments = faker.helpers.shuffle([...tournamentIds]);
+
+ for (let j = 0; j < copies && j < shuffledTournaments.length; j++) {
+ await insertOwner(trophyId, userId, shuffledTournaments[j]);
+ }
+
+ userIds.push(userId);
+ }
+ }
+
+ for (const trophyId of trophyIds) {
+ const shuffledTournaments = faker.helpers.shuffle([...tournamentIds]);
+ const copies = faker.number.int({ min: 1, max: 3 });
+
+ for (let i = 0; i < copies && i < shuffledTournaments.length; i++) {
+ await insertOwner(trophyId, ADMIN_ID, shuffledTournaments[i]);
+ }
+ }
+}
+
+const TROPHY_TOURNAMENT_ID = 9;
+const TROPHY_EVENT_ID = 209;
+const TROPHY_TEAM_ID_OFFSET = 800;
+// bracket rows use explicit non-positive ids so brackets started at runtime
+// (and tests referencing their seeded match ids) keep autoincrementing from 1
+const TROPHY_STAGE_ID = 0;
+const TROPHY_GROUP_ID = 0;
+const TROPHY_ROUND_ID_OFFSET = -3;
+const TROPHY_MATCH_ID_OFFSET = -7;
+const TROPHY_GAME_RESULT_ID_OFFSET = -14;
+
+async function trophyWinsTournament() {
+ await insertTournamentWithId({
+ id: TROPHY_TOURNAMENT_ID,
+ mapPickingStyle: "AUTO_ALL",
+ settings: JSON.stringify({
+ isRanked: true,
+ bracketProgression: [
+ {
+ type: "single_elimination",
+ name: "Bracket",
+ requiresCheckIn: false,
+ settings: { thirdPlaceMatch: false },
+ },
+ ],
+ }),
+ isFinalized: 1,
+ tier: 3,
+ });
+
+ await insertCalendarEventWithId({
+ id: TROPHY_EVENT_ID,
+ name: "Trophy Cup",
+ description: "Finished tournament with a trophy prize",
+ discordInviteCode: "test",
+ bracketUrl: "https://example.com",
+ authorId: ADMIN_ID,
+ tournamentId: TROPHY_TOURNAMENT_ID,
+ trophyId: 1,
+ });
+
+ await db
+ .insertInto("CalendarEventDate")
+ .values({
+ eventId: TROPHY_EVENT_ID,
+ startsAt: dateToDatabaseTimestamp(
+ new Date(Date.now() - 1000 * 60 * 60 * 24 * 21),
+ ),
+ })
+ .execute();
+
+ const userIds = await userIdsInAscendingOrderById();
+ const teamNames = [
+ "Splat Society",
+ "Ink Theory",
+ "Booyah Brigade",
+ "Chargers Anonymous",
+ "Squid Parts",
+ "Woomy Council",
+ "Roller Coalition",
+ "Last Splash",
+ ];
+ const teamMembers = new Map();
+
+ for (let i = 0; i < 8; i++) {
+ const teamId = TROPHY_TEAM_ID_OFFSET + i + 1;
+
+ await insertTournamentTeamWithId({
+ id: teamId,
+ name: teamNames[i],
+ createdAt: dateToDatabaseTimestamp(new Date()),
+ tournamentId: TROPHY_TOURNAMENT_ID,
+ inviteCode: shortNanoid(),
+ seed: i + 1,
+ });
+
+ await db
+ .insertInto("TournamentTeamCheckIn")
+ .values({
+ tournamentTeamId: teamId,
+ checkedInAt: dateToDatabaseTimestamp(new Date()),
+ })
+ .execute();
+
+ const members: number[] = [];
+ for (let j = 0; j < 4; j++) {
+ const userId = userIds.shift()!;
+ members.push(userId);
+
+ await db
+ .insertInto("TournamentTeamMember")
+ .values({
+ tournamentTeamId: teamId,
+ userId,
+ createdAt: dateToDatabaseTimestamp(new Date()),
+ role: j === 0 ? "OWNER" : "REGULAR",
+ })
+ .execute();
+ }
+ teamMembers.set(teamId, members);
+ }
+
+ // Bracket structure
+ const stageId = TROPHY_STAGE_ID;
+ await insertTournamentStageWithId({
+ id: stageId,
+ tournamentId: TROPHY_TOURNAMENT_ID,
+ name: "Bracket",
+ number: 1,
+ type: "single_elimination",
+ settings: JSON.stringify({ thirdPlaceMatch: false }),
+ });
+
+ const groupId = TROPHY_GROUP_ID;
+ await insertTournamentGroupWithId({ id: groupId, stageId, number: 1 });
+
+ const roundMaps = JSON.stringify({ count: 3, type: "BEST_OF" });
+
+ const roundIds: number[] = [];
+ for (let r = 1; r <= 3; r++) {
+ const roundId = TROPHY_ROUND_ID_OFFSET + r;
+ await insertTournamentRoundWithId({
+ id: roundId,
+ stageId,
+ groupId,
+ number: r,
+ maps: roundMaps,
+ });
+ roundIds.push(roundId);
+ }
+
+ const t = (seed: number) => TROPHY_TEAM_ID_OFFSET + seed;
+
+ // SE 8-team bracket: standard seeding, higher seed always wins
+ const matches = [
+ { round: 0, number: 1, team1: t(1), team2: t(8), winner: t(1) },
+ { round: 0, number: 2, team1: t(4), team2: t(5), winner: t(4) },
+ { round: 0, number: 3, team1: t(2), team2: t(7), winner: t(2) },
+ { round: 0, number: 4, team1: t(3), team2: t(6), winner: t(3) },
+ { round: 1, number: 1, team1: t(1), team2: t(4), winner: t(1) },
+ { round: 1, number: 2, team1: t(2), team2: t(3), winner: t(2) },
+ { round: 2, number: 1, team1: t(1), team2: t(2), winner: t(1) },
+ ];
+
+ const weaponPools = new Map();
+ const weaponPoolFor = (userId: number) => {
+ const existing = weaponPools.get(userId);
+ if (existing) return existing;
+
+ const pool = faker.helpers.arrayElements(canonicalMainWeaponIds, {
+ min: 1,
+ max: 3,
+ });
+ weaponPools.set(userId, pool);
+ return pool;
+ };
+
+ let gameResultId = TROPHY_GAME_RESULT_ID_OFFSET;
+ for (const [matchIndex, m] of matches.entries()) {
+ const matchId = TROPHY_MATCH_ID_OFFSET + matchIndex + 1;
+ await insertTournamentMatchWithId({
+ id: matchId,
+ stageId,
+ groupId,
+ roundId: roundIds[m.round],
+ number: m.number,
+ opponentOne: JSON.stringify({
+ id: m.team1,
+ score: m.winner === m.team1 ? 2 : 0,
+ }),
+ opponentTwo: JSON.stringify({
+ id: m.team2,
+ score: m.winner === m.team2 ? 2 : 0,
+ }),
+ winnerSide: m.winner === m.team1 ? "opponent1" : "opponent2",
+ });
+
+ // 2 game results (2-0 sweep) with weapons reported for every player
+ for (let g = 1; g <= 2; g++) {
+ gameResultId += 1;
+ await insertTournamentMatchGameResultWithId({
+ id: gameResultId,
+ matchId,
+ mode: "SZ",
+ number: g,
+ reporterId: ADMIN_ID,
+ source: "DEFAULT",
+ stageId: 1,
+ winnerTeamId: m.winner,
+ });
+
+ for (const teamId of [m.team1, m.team2]) {
+ for (const userId of teamMembers.get(teamId)!) {
+ await db
+ .insertInto("ReportedWeapon")
+ .values({
+ tournamentMatchId: matchId,
+ mapIndex: g - 1,
+ weaponSplId: faker.helpers.arrayElement(weaponPoolFor(userId)),
+ userId,
+ createdAt: dateToDatabaseTimestamp(new Date()),
+ })
+ .execute();
+ }
+ }
+ }
+ }
+
+ const placements = [
+ { teamSeed: 1, placement: 1, setResults: ["W", "W", "W"] },
+ { teamSeed: 2, placement: 2, setResults: ["W", "W", "L"] },
+ { teamSeed: 3, placement: 3, setResults: ["W", "L"] },
+ { teamSeed: 4, placement: 3, setResults: ["W", "L"] },
+ { teamSeed: 5, placement: 5, setResults: ["L"] },
+ { teamSeed: 6, placement: 5, setResults: ["L"] },
+ { teamSeed: 7, placement: 5, setResults: ["L"] },
+ { teamSeed: 8, placement: 5, setResults: ["L"] },
+ ];
+
+ for (const p of placements) {
+ const teamId = t(p.teamSeed);
+
+ for (const [memberIndex, userId] of teamMembers.get(teamId)!.entries()) {
+ const setResults = p.setResults.map((result) =>
+ memberIndex > 0 && faker.number.float(1) > 0.75 ? null : result,
+ );
+ const spDiff =
+ Math.round(
+ (p.placement === 1
+ ? faker.number.float({ min: 80, max: 220 })
+ : faker.number.float({ min: -120, max: 60 })) * 10,
+ ) / 10;
+
+ await db
+ .insertInto("TournamentResult")
+ .values({
+ tournamentId: TROPHY_TOURNAMENT_ID,
+ tournamentTeamId: teamId,
+ userId,
+ placement: p.placement,
+ participantCount: 8,
+ setResults: JSON.stringify(setResults),
+ spDiff,
+ })
+ .execute();
+ }
+ }
+
+ for (const userId of teamMembers.get(t(1))!) {
+ await db
+ .insertInto("TrophyOwner")
+ .values({
+ trophyId: 1,
+ userId,
+ tournamentId: TROPHY_TOURNAMENT_ID,
+ tier: 3,
+ })
+ .execute();
+ }
+}
+
+const UPCOMING_TROPHY_TOURNAMENT_ID = 10;
+const UPCOMING_TROPHY_EVENT_ID = 210;
+
+async function upcomingTrophyTournament() {
+ await insertTournamentWithId({
+ id: UPCOMING_TROPHY_TOURNAMENT_ID,
+ mapPickingStyle: "AUTO_ALL",
+ settings: JSON.stringify({
+ isRanked: true,
+ bracketProgression: [
+ {
+ type: "single_elimination",
+ name: "Bracket",
+ requiresCheckIn: false,
+ settings: { thirdPlaceMatch: false },
+ },
+ ],
+ }),
+ });
+
+ await insertCalendarEventWithId({
+ id: UPCOMING_TROPHY_EVENT_ID,
+ name: "Trophy Cup 2",
+ description: "Upcoming tournament with a trophy prize",
+ discordInviteCode: "test",
+ bracketUrl: "https://example.com",
+ authorId: ADMIN_ID,
+ tournamentId: UPCOMING_TROPHY_TOURNAMENT_ID,
+ trophyId: 1,
+ });
+
+ await db
+ .insertInto("CalendarEventDate")
+ .values({
+ eventId: UPCOMING_TROPHY_EVENT_ID,
+ startsAt: dateToDatabaseTimestamp(
+ new Date(Date.now() + 1000 * 60 * 60 * 24 * 10),
+ ),
+ })
+ .execute();
+}
+
+async function pendingTrophiesToDb() {
+ const userIds = (
+ await db
+ .selectFrom("User")
+ .select("id")
+ .where("id", "!=", ADMIN_ID)
+ .orderBy(sql`random()`)
+ .limit(10)
+ .execute()
+ ).map((row) => row.id);
+
+ const orgIds = (
+ await db
+ .selectFrom("TournamentOrganization")
+ .select("id")
+ .limit(5)
+ .execute()
+ ).map((row) => row.id);
+
+ const trophyEntries = Object.entries(trophies);
+
+ const insertPending = (values: {
+ name: string;
+ model: string;
+ createdAt: number;
+ declineReason?: string | null;
+ declinedAt?: number | null;
+ declinedByUserId?: number | null;
+ }) =>
+ db
+ .insertInto("PendingTrophy")
+ .values({
+ name: values.name,
+ model: values.model,
+ description: faker.lorem.sentence(),
+ organizationId: faker.helpers.arrayElement(orgIds),
+ submitterUserId: faker.helpers.arrayElement(userIds),
+ createdAt: values.createdAt,
+ declineReason: values.declineReason ?? null,
+ declinedAt: values.declinedAt ?? null,
+ declinedByUserId: values.declinedByUserId ?? null,
+ })
+ .returning("id")
+ .executeTakeFirstOrThrow();
+
+ const insertApproval = (values: {
+ pendingTrophyId: number;
+ userId: number;
+ createdAt: number;
+ }) => db.insertInto("PendingTrophyApproval").values(values).execute();
+
+ const now = Math.floor(Date.now() / 1000);
+
+ // 5 pending
+ for (let i = 0; i < 5; i++) {
+ const [trophyName, model] = trophyEntries[i % trophyEntries.length];
+ await insertPending({
+ name: `Pending ${trophyName} ${i + 1}`,
+ model,
+ createdAt: now - i * 3600,
+ });
+ }
+
+ // 2 with one approval
+ for (let i = 0; i < 2; i++) {
+ const [trophyName, model] = trophyEntries[(i + 5) % trophyEntries.length];
+ const pending = await insertPending({
+ name: `Partial ${trophyName} ${i + 1}`,
+ model,
+ createdAt: now - (i + 5) * 3600,
+ });
+
+ await insertApproval({
+ pendingTrophyId: pending.id,
+ userId: ADMIN_ID,
+ createdAt: now - i * 1800,
+ });
+ }
+
+ // 3 fully approved
+ for (let i = 0; i < 3; i++) {
+ const [trophyName, model] = trophyEntries[(i + 7) % trophyEntries.length];
+ const pending = await insertPending({
+ name: `Accepted ${trophyName} ${i + 1}`,
+ model,
+ createdAt: now - (i + 7) * 3600,
+ });
+
+ await insertApproval({
+ pendingTrophyId: pending.id,
+ userId: ADMIN_ID,
+ createdAt: now - (i + 1) * 1800,
+ });
+ await insertApproval({
+ pendingTrophyId: pending.id,
+ userId: NZAP_TEST_ID,
+ createdAt: now - i * 1800,
+ });
+ }
+
+ // 3 declined
+ for (let i = 0; i < 3; i++) {
+ const [trophyName, model] = trophyEntries[(i + 10) % trophyEntries.length];
+ await insertPending({
+ name: `Declined ${trophyName} ${i + 1}`,
+ model,
+ createdAt: now - (i + 10) * 3600,
+ declineReason: faker.lorem.sentence(),
+ declinedAt: now - i * 1800,
+ declinedByUserId: ADMIN_ID,
+ });
+ }
+}
+
+async function assignTrophyToTournament() {
+ const PICNIC_EVENT_ID = 201;
+ await db
+ .deleteFrom("CalendarEventBadge")
+ .where("eventId", "=", PICNIC_EVENT_ID)
+ .execute();
+ await db
+ .updateTable("CalendarEvent")
+ .set({ trophyId: 1 })
+ .where("id", "=", PICNIC_EVENT_ID)
+ .execute();
+}
+
+async function specialTrophies() {
+ const models = Object.values(trophies);
+
+ const specialTrophyValues = [
+ {
+ name: "Supporter",
+ model: models[0 % models.length],
+ code: SUPPORTER_TROPHY_CODE,
+ },
+ {
+ name: "3000 X Power",
+ model: models[1 % models.length],
+ code: `${XP_TROPHY_CODE_PREFIX}3000`,
+ },
+ {
+ name: "2600 X Power",
+ model: models[2 % models.length],
+ code: `${XP_TROPHY_CODE_PREFIX}2600`,
+ },
+ ];
+
+ for (const trophy of specialTrophyValues) {
+ await db.insertInto("Trophy").values(trophy).execute();
+ }
+
+ await TrophyRepository.syncSpecialTrophies();
+}
+
async function patrons() {
const userIds = (
await db
@@ -2184,8 +2746,8 @@ async function realVideoCast() {
}
// some copy+paste from placements script
-function xRankPlacements() {
- return db.transaction().execute(async (trx) => {
+async function xRankPlacements() {
+ await db.transaction().execute(async (trx) => {
for (const [i, placement] of placements.entries()) {
const userId = () => {
// admin
@@ -2219,6 +2781,8 @@ function xRankPlacements() {
.execute();
}
});
+
+ await XRankPlacementRepository.refreshAllPeakXp();
}
const addUnvalidatedUserSubmittedImage = (url: string, authorId: number) =>
@@ -3381,10 +3945,11 @@ function insertTournamentWithId(values: {
mapPickingStyle: TournamentMapPickingStyle;
settings: string;
isFinalized?: DBBoolean;
+ tier?: TournamentTierNumber | null;
}) {
return sql`
- insert into "Tournament" ("id", "mapPickingStyle", "settings", "isFinalized")
- values (${values.id}, ${values.mapPickingStyle}, ${values.settings}, ${values.isFinalized ?? 0})
+ insert into "Tournament" ("id", "mapPickingStyle", "settings", "isFinalized", "tier")
+ values (${values.id}, ${values.mapPickingStyle}, ${values.settings}, ${values.isFinalized ?? 0}, ${values.tier ?? null})
`.execute(db);
}
@@ -3399,10 +3964,11 @@ function insertCalendarEventWithId(values: {
organizationId?: number | null;
avatarImgId?: number | null;
tags?: string | null;
+ trophyId?: number | null;
}) {
return sql`
- insert into "CalendarEvent" ("id", "name", "description", "discordInviteCode", "bracketUrl", "authorId", "tournamentId", "organizationId", "avatarImgId", "tags")
- values (${values.id}, ${values.name}, ${values.description}, ${values.discordInviteCode}, ${values.bracketUrl}, ${values.authorId}, ${values.tournamentId ?? null}, ${values.organizationId ?? null}, ${values.avatarImgId ?? null}, ${values.tags ?? null})
+ insert into "CalendarEvent" ("id", "name", "description", "discordInviteCode", "bracketUrl", "authorId", "tournamentId", "organizationId", "avatarImgId", "tags", "trophyId")
+ values (${values.id}, ${values.name}, ${values.description}, ${values.discordInviteCode}, ${values.bracketUrl}, ${values.authorId}, ${values.tournamentId ?? null}, ${values.organizationId ?? null}, ${values.avatarImgId ?? null}, ${values.tags ?? null}, ${values.trophyId ?? null})
`.execute(db);
}
@@ -3419,3 +3985,73 @@ function insertTournamentTeamWithId(values: {
values (${values.id}, ${values.name}, ${values.createdAt}, ${values.tournamentId}, ${values.inviteCode}, ${values.seed})
`.execute(db);
}
+
+function insertTournamentStageWithId(values: {
+ id: number;
+ tournamentId: number;
+ name: string;
+ number: number;
+ type: string;
+ settings: string;
+}) {
+ return sql`
+ insert into "TournamentStage" ("id", "tournamentId", "name", "number", "type", "settings")
+ values (${values.id}, ${values.tournamentId}, ${values.name}, ${values.number}, ${values.type}, ${values.settings})
+ `.execute(db);
+}
+
+function insertTournamentGroupWithId(values: {
+ id: number;
+ stageId: number;
+ number: number;
+}) {
+ return sql`
+ insert into "TournamentGroup" ("id", "stageId", "number")
+ values (${values.id}, ${values.stageId}, ${values.number})
+ `.execute(db);
+}
+
+function insertTournamentRoundWithId(values: {
+ id: number;
+ stageId: number;
+ groupId: number;
+ number: number;
+ maps: string;
+}) {
+ return sql`
+ insert into "TournamentRound" ("id", "stageId", "groupId", "number", "maps")
+ values (${values.id}, ${values.stageId}, ${values.groupId}, ${values.number}, ${values.maps})
+ `.execute(db);
+}
+
+function insertTournamentMatchWithId(values: {
+ id: number;
+ stageId: number;
+ groupId: number;
+ roundId: number;
+ number: number;
+ opponentOne: string;
+ opponentTwo: string;
+ winnerSide: "opponent1" | "opponent2";
+}) {
+ return sql`
+ insert into "TournamentMatch" ("id", "stageId", "groupId", "roundId", "number", "opponentOne", "opponentTwo", "winnerSide")
+ values (${values.id}, ${values.stageId}, ${values.groupId}, ${values.roundId}, ${values.number}, ${values.opponentOne}, ${values.opponentTwo}, ${values.winnerSide})
+ `.execute(db);
+}
+
+function insertTournamentMatchGameResultWithId(values: {
+ id: number;
+ matchId: number;
+ mode: string;
+ number: number;
+ reporterId: number;
+ source: string;
+ stageId: number;
+ winnerTeamId: number;
+}) {
+ return sql`
+ insert into "TournamentMatchGameResult" ("id", "matchId", "mode", "number", "reporterId", "source", "stageId", "winnerTeamId")
+ values (${values.id}, ${values.matchId}, ${values.mode}, ${values.number}, ${values.reporterId}, ${values.source}, ${values.stageId}, ${values.winnerTeamId})
+ `.execute(db);
+}
diff --git a/app/db/seed/trophies.json b/app/db/seed/trophies.json
new file mode 100644
index 000000000..76272c258
--- /dev/null
+++ b/app/db/seed/trophies.json
@@ -0,0 +1,5 @@
+{
+ "Wellstring Wednesday": "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",
+ "Gun With a Really Long Name For No Reason": "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",
+ "Chris P. Bacon": "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"
+}
diff --git a/app/db/tables.ts b/app/db/tables.ts
index 7a1111bd0..2b9bb82c3 100644
--- a/app/db/tables.ts
+++ b/app/db/tables.ts
@@ -165,6 +165,51 @@ export interface BadgeOwner {
count: number;
}
+export interface Trophy {
+ id: GeneratedAlways;
+ name: string;
+ model: string;
+ /** Identifies special trophies, null for regular trophies. */
+ code: Generated;
+ organizationId: number | null;
+ creatorId: number | null;
+ managerId: number | null;
+}
+
+export interface TrophyOwner {
+ trophyId: number;
+ userId: number;
+ tournamentId: number;
+ tier: number | null;
+}
+
+export interface SpecialTrophyOwner {
+ trophyId: number;
+ userId: number;
+ createdAt: number;
+}
+
+export interface PendingTrophy {
+ id: GeneratedAlways;
+ name: string;
+ model: string;
+ description: string;
+ organizationId: number | null;
+ submitterUserId: number;
+ createdAt: number;
+ declineReason: string | null;
+ declinedAt: number | null;
+ declinedByUserId: number | null;
+ targetTrophyId: number | null;
+ managerId: number | null;
+}
+
+export interface PendingTrophyApproval {
+ pendingTrophyId: number;
+ userId: number;
+ createdAt: number;
+}
+
export interface Build {
clothesGearSplId: number | null;
description: string | null;
@@ -222,6 +267,7 @@ export interface CalendarEvent {
tournamentId: number | null;
organizationId: number | null;
avatarImgId: number | null;
+ trophyId: number | null;
}
export interface CalendarEventBadge {
@@ -807,6 +853,8 @@ export interface User {
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;
inGameName: string | null;
isArtist: Generated;
@@ -1245,6 +1293,11 @@ export interface DB {
TournamentOrganizationBannedUser: TournamentOrganizationBannedUser;
TournamentStreamer: TournamentStreamer;
TournamentMatchVod: TournamentMatchVod;
+ Trophy: Trophy;
+ TrophyOwner: TrophyOwner;
+ SpecialTrophyOwner: SpecialTrophyOwner;
+ PendingTrophy: PendingTrophy;
+ PendingTrophyApproval: PendingTrophyApproval;
TrustRelationship: TrustRelationship;
Friendship: Friendship;
FriendRequest: FriendRequest;
diff --git a/app/features/admin/AdminRepository.server.ts b/app/features/admin/AdminRepository.server.ts
index 9104a94f6..29718712b 100644
--- a/app/features/admin/AdminRepository.server.ts
+++ b/app/features/admin/AdminRepository.server.ts
@@ -5,6 +5,7 @@ import { actorId } from "~/features/auth/core/user.server";
import * as BadgeRepository from "~/features/badges/BadgeRepository.server";
import * as BuildRepository from "~/features/builds/BuildRepository.server";
import * as XRankPlacementRepository from "~/features/top-search/XRankPlacementRepository.server";
+import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import invariant from "~/utils/invariant";
@@ -283,18 +284,19 @@ export async function linkUserAndPlayer({
.execute();
await BadgeRepository.syncXPBadges();
+ await TrophyRepository.syncSpecialTrophies();
await BuildRepository.recalculateAllSortValues(userId);
await XRankPlacementRepository.refreshTenStarWeapons(userId);
}
-export function forcePatron(args: {
+export async function forcePatron(args: {
id: number;
patronTier: Tables["User"]["patronTier"];
patronStartedAt: Date;
patronExpiresAt: Date;
}) {
- return db
+ await db
.updateTable("User")
.set({
patronTier: args.patronTier,
@@ -303,6 +305,8 @@ export function forcePatron(args: {
})
.where("User.id", "=", args.id)
.execute();
+
+ await TrophyRepository.syncSpecialTrophies();
}
export async function findAllBannedUsers() {
diff --git a/app/features/admin/admin-constants.ts b/app/features/admin/admin-constants.ts
index 3a72e5175..a8526ca30 100644
--- a/app/features/admin/admin-constants.ts
+++ b/app/features/admin/admin-constants.ts
@@ -7,6 +7,8 @@ export const ADMIN_ID = process.env.NODE_ENV === "test" ? 1 : 274;
export const STAFF_IDS = [11329, 9719, 9342, 20774, 23094];
// hfcRed
export const DEV_IDS = [27883];
+// hfcRed Dreamy Cafy
+export const QA_IDS: number[] = [27883, 38781, 10654];
export const STAFF_DISCORD_IDS = [
"138757634500067328",
diff --git a/app/features/api-private/routes/seed.ts b/app/features/api-private/routes/seed.ts
index 7a3cdd5e3..5f2699675 100644
--- a/app/features/api-private/routes/seed.ts
+++ b/app/features/api-private/routes/seed.ts
@@ -63,6 +63,8 @@ export const action: ActionFunction = async ({ request }) => {
};
const REG_OPEN_TOURNAMENT_IDS = [1, 3];
+const FINISHED_IN_THE_PAST_EVENT_IDS = [209];
+const UPCOMING_EVENT_IDS = [210];
const SEED_REFERENCE_TIMESTAMP = 1767440151;
@@ -76,6 +78,9 @@ async function adjustSeedDatesToCurrent(variation: SeedVariation) {
1000,
);
const now = Math.floor(Date.now() / 1000);
+ const tenDaysFromNow = Math.floor(
+ (Date.now() + 1000 * 60 * 60 * 24 * 10) / 1000,
+ );
const tournamentEventIds = await db
.selectFrom("CalendarEvent")
@@ -85,6 +90,17 @@ async function adjustSeedDatesToCurrent(variation: SeedVariation) {
.execute();
for (const { id, tournamentId } of tournamentEventIds) {
+ if (FINISHED_IN_THE_PAST_EVENT_IDS.includes(id)) continue;
+
+ if (UPCOMING_EVENT_IDS.includes(id)) {
+ await db
+ .updateTable("CalendarEventDate")
+ .set({ startsAt: tenDaysFromNow })
+ .where("eventId", "=", id)
+ .execute();
+ continue;
+ }
+
const isRegOpen =
variation === "REG_OPEN" &&
REG_OPEN_TOURNAMENT_IDS.includes(tournamentId);
diff --git a/app/features/badges/components/BadgeDisplay.module.css b/app/features/badges/components/BadgeDisplay.module.css
index d9e3fd3dc..733c40b7c 100644
--- a/app/features/badges/components/BadgeDisplay.module.css
+++ b/app/features/badges/components/BadgeDisplay.module.css
@@ -46,29 +46,5 @@
}
.pagination {
- display: flex;
- flex-wrap: wrap;
- gap: var(--s-3);
- justify-content: center;
- align-items: center;
- max-width: 20rem;
- margin: 0 auto;
margin-block-start: var(--s-2);
}
-
-.paginationButton {
- background-color: var(--color-bg);
- border-radius: 100%;
- padding: var(--s-1);
- height: 24px;
- width: 24px;
- border: var(--border-style);
- font-size: var(--font-xs);
- color: var(--color-text-high);
-}
-
-.paginationButtonActive {
- color: var(--color-text-accent);
- background-color: var(--color-bg-high);
- border-color: var(--color-border-high);
-}
diff --git a/app/features/badges/components/BadgeDisplay.tsx b/app/features/badges/components/BadgeDisplay.tsx
index 3d9b5cc7c..73852d11b 100644
--- a/app/features/badges/components/BadgeDisplay.tsx
+++ b/app/features/badges/components/BadgeDisplay.tsx
@@ -3,6 +3,7 @@ import { Trash } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Badge } from "~/components/Badge";
+import { DotPagination } from "~/components/DotPagination";
import { SendouButton } from "~/components/elements/Button";
import type { Tables } from "~/db/tables";
import { BADGE } from "~/features/badges/badges-constants";
@@ -108,43 +109,15 @@ export function BadgeDisplay({
) : null}
{!everythingVisible ? (
-
) : null}
);
}
-
-interface BadgePaginationProps {
- pagesCount: number;
- currentPage: number;
- setPage: (page: number) => void;
-}
-
-function BadgePagination({
- pagesCount,
- currentPage,
- setPage,
-}: BadgePaginationProps) {
- return (
-
- {Array.from({ length: pagesCount }, (_, i) => (
- setPage(i + 1)}
- className={clsx(styles.paginationButton, {
- [styles.paginationButtonActive]: currentPage === i + 1,
- })}
- data-testid="badge-pagination-button"
- >
- {i + 1}
-
- ))}
-
- );
-}
diff --git a/app/features/badges/routes/badges.$id.tsx b/app/features/badges/routes/badges.$id.tsx
index 90cacf51d..13afd4cdd 100644
--- a/app/features/badges/routes/badges.$id.tsx
+++ b/app/features/badges/routes/badges.$id.tsx
@@ -90,7 +90,7 @@ export default function BadgeDetailsPage() {
Edit
) : null}
-
+
{data.badge.owners.map((owner) => (
diff --git a/app/features/calendar/CalendarRepository.server.ts b/app/features/calendar/CalendarRepository.server.ts
index a8734df17..eb0be7211 100644
--- a/app/features/calendar/CalendarRepository.server.ts
+++ b/app/features/calendar/CalendarRepository.server.ts
@@ -82,6 +82,15 @@ const withBadgePrizes = (eb: ExpressionBuilder) => {
).as("badgePrizes");
};
+const withTrophy = (eb: ExpressionBuilder) => {
+ return jsonObjectFrom(
+ eb
+ .selectFrom("Trophy")
+ .select(["Trophy.id", "Trophy.name", "Trophy.model"])
+ .whereRef("Trophy.id", "=", "CalendarEvent.trophyId"),
+ ).as("trophy");
+};
+
function tournamentOrganization(organizationId: Expression) {
return jsonObjectFrom(
db
@@ -200,6 +209,12 @@ function findAllBetweenTwoTimestampsQuery({
)
.orderBy("Badge.id", "asc"),
).as("badges"),
+ jsonObjectFrom(
+ eb
+ .selectFrom("Trophy")
+ .select(["Trophy.model"])
+ .whereRef("Trophy.id", "=", "CalendarEvent.trophyId"),
+ ).as("trophy"),
])
.where("CalendarEvent.hidden", "=", 0)
.where(
@@ -256,6 +271,7 @@ function findAllBetweenTwoTimestampsMapped(
? modesIncluded(row.mapPickingStyle, row.toSetMapPool)
: null,
badges: row.badges,
+ trophy: row.trophy,
logoUrl: row.logoUrl,
startsAt: row.normalizedStartsAt,
isRanked: row.tournamentSettings
@@ -289,10 +305,12 @@ export async function findById(
includeMapPool = false,
includeTieBreakerMapPool = false,
includeBadgePrizes = false,
+ includeTrophy = false,
}: {
includeMapPool?: boolean;
includeTieBreakerMapPool?: boolean;
includeBadgePrizes?: boolean;
+ includeTrophy?: boolean;
} = {},
) {
const [firstRow, ...rest] = await db
@@ -300,6 +318,7 @@ export async function findById(
.$if(includeMapPool, (qb) => qb.select(withMapPool))
.$if(includeTieBreakerMapPool, (qb) => qb.select(withTieBreakerMapPool))
.$if(includeBadgePrizes, (qb) => qb.select(withBadgePrizes))
+ .$if(includeTrophy, (qb) => qb.select(withTrophy))
.innerJoin(
"CalendarEventDate",
"CalendarEvent.id",
@@ -407,6 +426,7 @@ type CreateArgs = Pick<
> & {
startTimes: Array;
badges: Array;
+ trophyId?: Tables["CalendarEvent"]["trophyId"];
mapPoolMaps?: Array>;
isFullTournament: boolean;
mapPickingStyle: Tables["Tournament"]["mapPickingStyle"];
@@ -521,6 +541,7 @@ export async function insert(args: CreateArgs) {
organizationId: args.organizationId,
hidden: args.parentTournamentId || args.isTest || args.isDraft ? 1 : 0,
tournamentId,
+ trophyId: args.trophyId ?? null,
})
.returning("id")
.executeTakeFirstOrThrow();
@@ -586,6 +607,7 @@ export async function update(args: UpdateArgs) {
bracketUrl: args.bracketUrl,
avatarImgId: args.avatarImgId ?? avatarImgId,
organizationId: args.organizationId,
+ trophyId: args.trophyId ?? null,
})
.where("id", "=", args.eventId)
.returning("tournamentId")
diff --git a/app/features/calendar/actions/calendar.new.server.ts b/app/features/calendar/actions/calendar.new.server.ts
index fae255ed6..8f0a94c1b 100644
--- a/app/features/calendar/actions/calendar.new.server.ts
+++ b/app/features/calendar/actions/calendar.new.server.ts
@@ -10,6 +10,8 @@ import {
clearTournamentDataCache,
tournamentFromDB,
} from "~/features/tournament-bracket/core/Tournament.server";
+import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
+import { canAccessTrophies } from "~/features/trophies/trophies-utils";
import { parseFormDataWithImages } from "~/form/parse.server";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import { requireRole } from "~/modules/permissions/guards.server";
@@ -60,6 +62,20 @@ export const action: ActionFunction = async ({ request }) => {
);
}
+ if (data.trophyId) {
+ if (!canAccessTrophies(user)) {
+ errorToast("Trophies are not released yet");
+ }
+
+ const trophyOrganizationId = await TrophyRepository.findOrganizationIdById(
+ data.trophyId,
+ );
+ if (trophyOrganizationId !== organizationId) {
+ errorToast("Trophy does not belong to the selected organization");
+ }
+ data.badges = [];
+ }
+
const managedBadges = await BadgeRepository.findManagedByUserId(user.id);
const dates =
@@ -86,6 +102,7 @@ export const action: ActionFunction = async ({ request }) => {
badges: data.badges.filter((badge) =>
managedBadges.some((mb) => mb.id === badge),
),
+ trophyId: data.trophyId ?? null,
// resolved by parseFormDataWithImages from the `image()` field
avatarImgId: data.avatarImgId ?? undefined,
toToolsEnabled: Number(data.toToolsEnabled),
diff --git a/app/features/calendar/calendar-new-schemas.ts b/app/features/calendar/calendar-new-schemas.ts
index 4b332e0c5..21b3699be 100644
--- a/app/features/calendar/calendar-new-schemas.ts
+++ b/app/features/calendar/calendar-new-schemas.ts
@@ -19,6 +19,7 @@ import {
toggle,
} from "~/form/fields";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
+import { id } from "~/utils/zod";
import { CALENDAR_EVENT, REG_CLOSES_AT_OPTIONS } from "./calendar-constants";
import { bracketProgressionSchema } from "./calendar-schemas";
import { calendarEventMaxDate, calendarEventMinDate } from "./calendar-utils";
@@ -82,6 +83,7 @@ export const calendarNewBaseSchema = z.object({
})),
}),
badges: badges({ label: "labels.badges", maxCount: 50 }),
+ trophyId: customField({ initialValue: null }, id.nullish()),
avatarImgId: image({
label: "labels.logo",
bottomText: "bottomTexts.avatarValidation",
@@ -212,6 +214,14 @@ export function calendarNewSyncRefine(
}
}
+ if (data.trophyId && data.badges.length > 0) {
+ ctx.addIssue({
+ path: ["badges"],
+ code: z.ZodIssueCode.custom,
+ message: "forms:errors.trophyWithBadges",
+ });
+ }
+
if (
data.toToolsEnabled &&
data.minMembersPerTeam === "4" &&
diff --git a/app/features/calendar/calendar-new.module.css b/app/features/calendar/calendar-new.module.css
new file mode 100644
index 000000000..336dfe9b7
--- /dev/null
+++ b/app/features/calendar/calendar-new.module.css
@@ -0,0 +1,5 @@
+.trophyPreview {
+ background-color: var(--color-bg-high);
+ border: var(--border-style);
+ border-radius: var(--radius-field);
+}
diff --git a/app/features/calendar/calendar-types.ts b/app/features/calendar/calendar-types.ts
index 4783a4e86..152e7b7d4 100644
--- a/app/features/calendar/calendar-types.ts
+++ b/app/features/calendar/calendar-types.ts
@@ -40,6 +40,7 @@ export interface CalendarEvent extends CommonEvent {
badges: Array<
Pick
> | null;
+ trophy: Pick | null;
}
export interface ShowcaseCalendarEvent extends CommonEvent {
diff --git a/app/features/calendar/components/TournamentCard.module.css b/app/features/calendar/components/TournamentCard.module.css
index c969cfaf7..9a5f43a3c 100644
--- a/app/features/calendar/components/TournamentCard.module.css
+++ b/app/features/calendar/components/TournamentCard.module.css
@@ -195,3 +195,8 @@
padding: 0 var(--s-1-5);
height: var(--selector-size);
}
+
+.trophyPreview {
+ background-color: var(--color-bg-high);
+ border-radius: var(--radius-box);
+}
diff --git a/app/features/calendar/components/TournamentCard.tsx b/app/features/calendar/components/TournamentCard.tsx
index b11400c8e..c602d8018 100644
--- a/app/features/calendar/components/TournamentCard.tsx
+++ b/app/features/calendar/components/TournamentCard.tsx
@@ -1,5 +1,5 @@
import clsx from "clsx";
-import { ShieldMinus, Trophy, Users } from "lucide-react";
+import { ShieldMinus, Trophy as TrophyIcon, Users } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { SendouButton } from "~/components/elements/Button";
@@ -9,6 +9,7 @@ import { Image, ModeImage } from "~/components/Image";
import { LocaleTime } from "~/components/LocaleTime";
import { TierPill } from "~/components/TierPill";
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
+import { Trophy } from "~/features/trophies/components/Trophy";
import { useFormatDistanceToNow } from "~/hooks/intl/useFormatDistanceToNow";
import { useHydrated } from "~/hooks/useHydrated";
import { useSpoilerFree } from "~/hooks/useSpoilerFree";
@@ -142,11 +143,16 @@ export function TournamentCard({
>
{tournament.isRanked ? (
-
+
) : null}
- {isCalendar && tournament.badges && tournament.badges.length > 0 ? (
-
+ {isCalendar &&
+ (tournament.trophy ||
+ (tournament.badges && tournament.badges.length > 0)) ? (
+
) : null}
{isHostedOnSendouInk ? (
@@ -292,10 +298,12 @@ function ModesPill({ modes }: { modes: NonNullable
}) {
);
}
-function BadgePrizesPill({
+function PrizesPill({
badges,
+ trophy,
}: {
- badges: NonNullable;
+ badges: CalendarEvent["badges"];
+ trophy?: string;
}) {
return (
}
>
-
+ {trophy ? (
+
+ ) : badges ? (
+
+ ) : null}
);
}
diff --git a/app/features/calendar/core/CalendarEvent.test.ts b/app/features/calendar/core/CalendarEvent.test.ts
index c18a41198..8220ad7da 100644
--- a/app/features/calendar/core/CalendarEvent.test.ts
+++ b/app/features/calendar/core/CalendarEvent.test.ts
@@ -22,6 +22,7 @@ function makeEvent(
type: "calendar",
normalizedTeamCount: 0,
badges: [],
+ trophy: null,
logoUrl: null,
name: "",
url: "",
diff --git a/app/features/calendar/loaders/calendar.new.server.ts b/app/features/calendar/loaders/calendar.new.server.ts
index ac8980549..ac9f61a56 100644
--- a/app/features/calendar/loaders/calendar.new.server.ts
+++ b/app/features/calendar/loaders/calendar.new.server.ts
@@ -7,6 +7,8 @@ import * as CalendarRepository from "~/features/calendar/CalendarRepository.serv
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import { tournamentData } from "~/features/tournament-bracket/core/Tournament.server";
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
+import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
+import { canAccessTrophies } from "~/features/trophies/trophies-utils";
import { requireRole } from "~/modules/permissions/guards.server";
import { tournamentBracketsPage } from "~/utils/urls";
import { canEditCalendarEvent } from "../calendar-utils";
@@ -23,6 +25,7 @@ export const loader = async ({ url }: LoaderFunctionArgs) => {
includeMapPool: true,
includeTieBreakerMapPool: true,
includeBadgePrizes: true,
+ includeTrophy: true,
});
if (!event) return;
@@ -84,12 +87,25 @@ export const loader = async ({ url }: LoaderFunctionArgs) => {
? await eventWithTournament("copyEventId")
: undefined;
+ const validOrganizationIds = organizations.flatMap((org) =>
+ typeof org === "string" ? [] : [org.id],
+ );
+
+ const trophies = canAccessTrophies(user)
+ ? await TrophyRepository.findByOrganizationIds(validOrganizationIds)
+ : [];
+
const eventToCopy = eventToCopyRaw
? {
...eventToCopyRaw,
badgePrizes: eventToCopyRaw.badgePrizes?.filter((badge) =>
managedBadges.some((mb) => mb.id === badge.id),
),
+ trophy: eventToCopyRaw.trophy
+ ? trophies.some((t) => t.id === eventToCopyRaw.trophy?.id)
+ ? eventToCopyRaw.trophy
+ : null
+ : null,
}
: undefined;
@@ -120,6 +136,7 @@ export const loader = async ({ url }: LoaderFunctionArgs) => {
? await CalendarRepository.findRecentTournamentsByAuthorId(user.id)
: undefined,
organizations,
+ trophies,
};
};
diff --git a/app/features/calendar/loaders/calendar.server.ts b/app/features/calendar/loaders/calendar.server.ts
index 17415f2a5..d3f9d394d 100644
--- a/app/features/calendar/loaders/calendar.server.ts
+++ b/app/features/calendar/loaders/calendar.server.ts
@@ -7,6 +7,7 @@ import {
calendarFiltersSearchParamsObject,
calendarFiltersSearchParamsSchema,
} from "~/features/calendar/calendar-schemas";
+import { canAccessTrophies } from "~/features/trophies/trophies-utils";
import type { SerializeFrom } from "~/utils/remix";
import { parseSafeSearchParams, parseSearchParams } from "~/utils/remix.server";
import { dayMonthYear } from "~/utils/zod";
@@ -38,8 +39,24 @@ export const loader = async (args: LoaderFunctionArgs) => {
const filters = resolveFilters(args.request, user?.preferences);
const filtered = CalendarEvent.applyFilters(events, filters);
+ const eventTimes = canAccessTrophies(user)
+ ? filtered
+ : filtered.map((time) => ({
+ ...time,
+ events: {
+ shown: time.events.shown.map((event) => ({
+ ...event,
+ trophy: null,
+ })),
+ hidden: time.events.hidden.map((event) => ({
+ ...event,
+ trophy: null,
+ })),
+ },
+ }));
+
return {
- eventTimes: filtered,
+ eventTimes,
dateViewed: parsed.success ? parsed.data : undefined,
filters,
};
diff --git a/app/features/calendar/routes/calendar.new.tsx b/app/features/calendar/routes/calendar.new.tsx
index 9256e3f33..c4da05626 100644
--- a/app/features/calendar/routes/calendar.new.tsx
+++ b/app/features/calendar/routes/calendar.new.tsx
@@ -1,3 +1,4 @@
+import { Trash } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
@@ -13,6 +14,7 @@ import { SubmitButton } from "~/components/SubmitButton";
import type { Tables } from "~/db/tables";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import * as Progression from "~/features/tournament-bracket/core/Progression";
+import { Trophy } from "~/features/trophies/components/Trophy";
import { type CustomFieldRenderProps, FormField } from "~/form/FormField";
import { existingImage } from "~/form/image-field";
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
@@ -26,6 +28,7 @@ import type { SendouRouteHandle } from "~/utils/remix.server";
import { CREATING_TOURNAMENT_DOC_LINK, FAQ_PAGE } from "~/utils/urls";
import { action } from "../actions/calendar.new.server";
import type { RegClosesAtOption } from "../calendar-constants";
+import styles from "../calendar-new.module.css";
import { calendarNewBaseSchema } from "../calendar-new-schemas";
import { datesToRegClosesAt } from "../calendar-utils";
import { BracketProgressionSelector } from "../components/BracketProgressionSelector";
@@ -194,6 +197,7 @@ function useDefaultValues() {
discordInviteCode: baseEvent?.discordInviteCode ?? "",
tags: baseEvent?.tags ?? [],
badges: baseEvent?.badgePrizes?.map((b) => b.id) ?? [],
+ trophyId: baseEvent?.trophy?.id ?? null,
avatarImgId: existingImage(
baseEvent?.avatarImgId,
baseEvent?.tournament?.ctx.logoUrl,
@@ -291,6 +295,7 @@ function CalendarNewFields() {
{data.badgeOptions.length > 0 ? (
) : null}
+ {isTournament ? : null}
{isTournament ? : null}
{isTournament ? (
<>
@@ -331,6 +336,97 @@ function DescriptionField({ isTournament }: { isTournament: boolean }) {
);
}
+function TrophyField() {
+ const { t } = useTranslation("calendar");
+ const data = useLoaderData();
+ const { values, setValue } = useFormFieldContext();
+ const id = React.useId();
+
+ const organizationId = values.organizationId
+ ? Number(values.organizationId)
+ : null;
+ const trophyId = typeof values.trophyId === "number" ? values.trophyId : null;
+ const badgeCount = (values.badges as number[]).length;
+
+ // clear the trophy when the selected organization or badges make it invalid
+ React.useEffect(() => {
+ if (!trophyId) return;
+ const trophyStillValid =
+ badgeCount === 0 &&
+ data.trophies.some(
+ (trophy) =>
+ trophy.id === trophyId && trophy.organizationId === organizationId,
+ );
+ if (!trophyStillValid) {
+ setValue("trophyId", null);
+ }
+ }, [trophyId, badgeCount, organizationId, data.trophies, setValue]);
+
+ const availableTrophies = organizationId
+ ? data.trophies.filter((trophy) => trophy.organizationId === organizationId)
+ : [];
+
+ if (availableTrophies.length === 0 && trophyId === null) return null;
+
+ const selectedTrophy = trophyId
+ ? data.trophies.find((trophy) => trophy.id === trophyId)
+ : null;
+
+ return (
+
+ {({ onChange }: CustomFieldRenderProps) => {
+ const handleChange = (newTrophyId: number | null) => {
+ onChange(newTrophyId);
+ if (newTrophyId) {
+ setValue("badges", []);
+ }
+ };
+
+ return (
+
+
+ {t("forms.trophy")}
+ {
+ const value = e.target.value;
+ handleChange(value === "" ? null : Number(value));
+ }}
+ >
+ {t("forms.trophy.placeholder")}
+ {availableTrophies.map((trophy) => (
+
+ {trophy.name}
+
+ ))}
+
+
+ {selectedTrophy ? (
+
+
+
+ {selectedTrophy.name}
+ handleChange(null)}
+ icon={ }
+ variant="minimal-destructive"
+ aria-label="Remove trophy"
+ />
+
+
+ ) : null}
+
+ );
+ }}
+
+ );
+}
+
function MemberCountFields() {
const { values } = useFormFieldContext();
diff --git a/app/features/components-showcase/components-showcase.module.css b/app/features/components-showcase/components-showcase.module.css
index 271f9e30c..ada31b8d2 100644
--- a/app/features/components-showcase/components-showcase.module.css
+++ b/app/features/components-showcase/components-showcase.module.css
@@ -23,3 +23,15 @@
.componentContent {
width: 100%;
}
+
+.trophyExample {
+ width: 140px;
+}
+
+.trophyExampleSmall {
+ width: 90px;
+}
+
+.trophyExampleLarge {
+ width: 200px;
+}
diff --git a/app/features/components-showcase/example-trophy-model.ts b/app/features/components-showcase/example-trophy-model.ts
new file mode 100644
index 000000000..8fadb2227
--- /dev/null
+++ b/app/features/components-showcase/example-trophy-model.ts
@@ -0,0 +1,2 @@
+export const EXAMPLE_TROPHY_MODEL =
+ "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";
diff --git a/app/features/components-showcase/routes/components.tsx b/app/features/components-showcase/routes/components.tsx
index a989b7621..fa0a26048 100644
--- a/app/features/components-showcase/routes/components.tsx
+++ b/app/features/components-showcase/routes/components.tsx
@@ -1,4 +1,5 @@
import { parseDate } from "@internationalized/date";
+import clsx from "clsx";
import { Check, Plus, Search, SquarePen, Trash } from "lucide-react";
import { useState } from "react";
import { Ability } from "~/components/Ability";
@@ -48,7 +49,12 @@ import { StageSelect } from "~/components/StageSelect";
import { SubmitButton } from "~/components/SubmitButton";
import { SubNav, SubNavLink } from "~/components/SubNav";
import { Table } from "~/components/Table";
+import { TierPill } from "~/components/TierPill";
import { WeaponSelect } from "~/components/WeaponSelect";
+import {
+ Trophy,
+ TrophyContextProvider,
+} from "~/features/trophies/components/Trophy";
import { UserCard } from "~/features/user-card/components/UserCard";
import type { UserCardData } from "~/features/user-card/user-card-types";
import type { CustomFieldRenderProps } from "~/form/FormField";
@@ -56,6 +62,7 @@ import { SendouForm } from "~/form/SendouForm";
import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types";
import type { SendouRouteHandle } from "~/utils/remix.server";
import styles from "../components-showcase.module.css";
+import { EXAMPLE_TROPHY_MODEL } from "../example-trophy-model";
import { formFieldsShowcaseSchema } from "../form-examples-schema";
export const handle: SendouRouteHandle = {
@@ -102,6 +109,8 @@ export const SECTIONS = [
{ title: "Flags", id: "flags", component: FlagSection },
{ title: "Placements", id: "placements", component: PlacementSection },
{ title: "Badges", id: "badges", component: BadgeSection },
+ { title: "Trophies", id: "trophies", component: TrophySection },
+ { title: "Tier Pills", id: "tier-pills", component: TierPillSection },
{ title: "Game Selects", id: "game-selects", component: GameSelectSection },
{ title: "Form Fields", id: "form-fields", component: FormFieldsSection },
{ title: "Miscellaneous", id: "miscellaneous", component: MiscSection },
@@ -2048,6 +2057,115 @@ function BadgeSection({ id }: { id: string }) {
);
}
+function TrophySection({ id }: { id: string }) {
+ return (
+
+ Trophies
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {([1, 4, 9] as const).map((tier) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function TierPillSection({ id }: { id: string }) {
+ return (
+
+ Tier Pills
+
+
+
+
+ {[1, 2, 3, 4, 5, 6, 7, 8, 9].map((tier) => (
+
+ ))}
+
+
+
+
+
+ {[1, 4, 9].map((tier) => (
+
+ ))}
+
+
+
+
+ );
+}
+
function GameSelectSection({ id }: { id: string }) {
const [selectedWeapon, setSelectedWeapon] = useState(
null,
diff --git a/app/features/front-page/routes/index.tsx b/app/features/front-page/routes/index.tsx
index 59918949a..900024895 100644
--- a/app/features/front-page/routes/index.tsx
+++ b/app/features/front-page/routes/index.tsx
@@ -21,6 +21,7 @@ import { PWAInstallBanner } from "~/features/front-page/components/PWAInstallBan
import { SplatoonRotations } from "~/features/front-page/components/SplatoonRotations";
import type * as Changelog from "~/features/front-page/core/Changelog.server";
import * as Seasons from "~/features/mmr/core/Seasons";
+import { canAccessTrophies } from "~/features/trophies/trophies-utils";
import styles from "~/styles/front.module.css";
import { databaseTimestampToDate } from "~/utils/dates";
import type { SendouRouteHandle } from "~/utils/remix.server";
@@ -263,7 +264,7 @@ function ResultHighlights() {
>
{t("front:showcase.results")}
-
+
{data.tournaments.results.map((tournament) => (
))}
@@ -316,9 +317,12 @@ const DISCOVER_EXCLUDED_ITEMS = new Set(["settings", "luti"]);
function DiscoverFeatures() {
const { t } = useTranslation(["front", "common"]);
const data = useLoaderData
();
+ const user = useUser();
const filteredNavItems = navItems.filter(
- (item) => !DISCOVER_EXCLUDED_ITEMS.has(item.name),
+ (item) =>
+ !DISCOVER_EXCLUDED_ITEMS.has(item.name) &&
+ (item.name !== "trophies" || canAccessTrophies(user)),
);
return (
diff --git a/app/features/notifications/core/notify.server.ts b/app/features/notifications/core/notify.server.ts
index 30aa4dc9f..ef9b48395 100644
--- a/app/features/notifications/core/notify.server.ts
+++ b/app/features/notifications/core/notify.server.ts
@@ -22,6 +22,9 @@ const NOTIFICATION_URGENCY: Record = {
TO_LIKE_ACCEPTED: "high",
BADGE_ADDED: "normal",
BADGE_MANAGER_ADDED: "normal",
+ TROPHY_SUBMITTED: "normal",
+ TROPHY_SUBMISSION_ACCEPTED: "normal",
+ TROPHY_SUBMISSION_DECLINED: "normal",
PLUS_VOTING_STARTED: "normal",
PLUS_SUGGESTION_ADDED: "normal",
TAGGED_TO_ART: "normal",
diff --git a/app/features/notifications/notifications-types.ts b/app/features/notifications/notifications-types.ts
index 7c47aa34f..0cd12fdc5 100644
--- a/app/features/notifications/notifications-types.ts
+++ b/app/features/notifications/notifications-types.ts
@@ -49,6 +49,15 @@ export type Notification =
"BADGE_MANAGER_ADDED",
{ badgeName: string; badgeId: number }
>
+ | NotificationItem<
+ "TROPHY_SUBMITTED",
+ { trophyName: string; submitterUsername: string }
+ >
+ | NotificationItem<
+ "TROPHY_SUBMISSION_ACCEPTED",
+ { trophyName: string; trophyId: number }
+ >
+ | NotificationItem<"TROPHY_SUBMISSION_DECLINED", { trophyName: string }>
| NotificationItem<
"PLUS_VOTING_STARTED",
{
diff --git a/app/features/notifications/notifications-utils.ts b/app/features/notifications/notifications-utils.ts
index d2336245b..19f4a15ba 100644
--- a/app/features/notifications/notifications-utils.ts
+++ b/app/features/notifications/notifications-utils.ts
@@ -2,6 +2,7 @@ import { assertUnreachable } from "~/utils/types";
import {
badgePage,
FRIENDS_PAGE,
+ NEW_TROPHY_PAGE,
PLUS_VOTING_PAGE,
plusSuggestionPage,
SENDOUQ_PAGE,
@@ -12,6 +13,7 @@ import {
tournamentRegisterPage,
tournamentSubsPage,
tournamentTeamPage,
+ trophyPage,
userArtPage,
userEditProfilePage,
} from "~/utils/urls";
@@ -22,6 +24,10 @@ export const notificationNavIcon = (type: Notification["type"]) => {
case "BADGE_ADDED":
case "BADGE_MANAGER_ADDED":
return "badges";
+ case "TROPHY_SUBMITTED":
+ case "TROPHY_SUBMISSION_ACCEPTED":
+ case "TROPHY_SUBMISSION_DECLINED":
+ return "trophies";
case "PLUS_SUGGESTION_ADDED":
case "PLUS_VOTING_STARTED":
return "plus";
@@ -58,6 +64,11 @@ export const notificationLink = (notification: Notification) => {
return badgePage(notification.meta.badgeId);
case "BADGE_MANAGER_ADDED":
return badgePage(notification.meta.badgeId);
+ case "TROPHY_SUBMITTED":
+ case "TROPHY_SUBMISSION_DECLINED":
+ return NEW_TROPHY_PAGE;
+ case "TROPHY_SUBMISSION_ACCEPTED":
+ return trophyPage(notification.meta.trophyId);
case "PLUS_SUGGESTION_ADDED":
return plusSuggestionPage({ tier: notification.meta.tier });
case "PLUS_VOTING_STARTED":
diff --git a/app/features/search/routes/search.ts b/app/features/search/routes/search.ts
index b1fb9473d..7872cdf20 100644
--- a/app/features/search/routes/search.ts
+++ b/app/features/search/routes/search.ts
@@ -76,10 +76,16 @@ async function searchByType({
}));
}
case "organizations": {
- const orgs = await TournamentOrganizationRepository.searchByName({
- query,
- limit,
- });
+ const numericQuery = /^\d+$/.test(query) ? Number(query) : null;
+ const orgs = numericQuery
+ ? await TournamentOrganizationRepository.findOneById(numericQuery).then(
+ (o) => (o ? [o] : []),
+ )
+ : await TournamentOrganizationRepository.searchByName({
+ query,
+ limit,
+ });
+
return orgs.map((o) => ({
type: "organization" as const,
id: o.id,
diff --git a/app/features/tier-list-maker/hooks/useTierList.ts b/app/features/tier-list-maker/hooks/useTierList.ts
index 4db4dcaa9..70cdc63f1 100644
--- a/app/features/tier-list-maker/hooks/useTierList.ts
+++ b/app/features/tier-list-maker/hooks/useTierList.ts
@@ -4,7 +4,6 @@ import type {
DragStartEvent,
} from "@dnd-kit/core";
import { arrayMove } from "@dnd-kit/sortable";
-import JSONCrush from "jsoncrush";
import * as React from "react";
import { useSearchParams } from "react-router";
import { z } from "zod";
@@ -31,7 +30,12 @@ import {
tierListItemTypeSchema,
tierListStateSerializedSchema,
} from "../tier-list-maker-schemas";
-import { addItemToTier, getNextNthForItem } from "../tier-list-maker-utils";
+import {
+ addItemToTier,
+ compress,
+ decompress,
+ getNextNthForItem,
+} from "../tier-list-maker-utils";
export type TierListPlacementMode = "track" | "click";
@@ -517,11 +521,10 @@ function useSearchParamTiersState() {
try {
if (param) {
- const uncrushed = JSONCrush.uncrush(param);
+ const decompressed = decompress(param);
+ if (decompressed === null) throw new Error("Failed to decompress");
- const parsed = tierListStateSerializedSchema.parse(
- JSON.parse(uncrushed),
- );
+ const parsed = tierListStateSerializedSchema.parse(decompressed);
return {
tiers: parsed.tiers,
@@ -539,12 +542,14 @@ function useSearchParamTiersState() {
const persistTiersStateToParams = (state: TierListState) => {
const searchParams = new URLSearchParams(window.location.search);
- const serializedState = JSON.stringify({
- tiers: state.tiers,
- tierItems: Array.from(state.tierItems.entries()),
- });
+ searchParams.set(
+ TIER_SEARCH_PARAM_NAME,
+ compress({
+ tiers: state.tiers,
+ tierItems: Array.from(state.tierItems.entries()),
+ }),
+ );
- searchParams.set(TIER_SEARCH_PARAM_NAME, JSONCrush.crush(serializedState));
window.history.replaceState(
{},
"",
diff --git a/app/features/tier-list-maker/tier-list-maker-utils.ts b/app/features/tier-list-maker/tier-list-maker-utils.ts
index e56babbea..c754da7b5 100644
--- a/app/features/tier-list-maker/tier-list-maker-utils.ts
+++ b/app/features/tier-list-maker/tier-list-maker-utils.ts
@@ -1,3 +1,4 @@
+import { compressToBase64, decompressFromBase64 } from "~/utils/compression";
import type { TierListItem, TierListState } from "./tier-list-maker-schemas";
export function tierListItemId(item: TierListItem) {
@@ -50,3 +51,18 @@ export function getNextNthForItem(
}, 0) + 1
);
}
+
+export function compress(obj: T) {
+ return compressToBase64(JSON.stringify(obj), { urlSafe: true });
+}
+
+export function decompress(compressed: string) {
+ const json = decompressFromBase64(compressed);
+ if (json === null) return null;
+
+ try {
+ return JSON.parse(json) as T;
+ } catch {
+ return null;
+ }
+}
diff --git a/app/features/top-search/actions/xsearch.player.$id.server.ts b/app/features/top-search/actions/xsearch.player.$id.server.ts
index 5845ef91e..38ccd91ca 100644
--- a/app/features/top-search/actions/xsearch.player.$id.server.ts
+++ b/app/features/top-search/actions/xsearch.player.$id.server.ts
@@ -1,6 +1,7 @@
import type { ActionFunctionArgs } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import * as BadgeRepository from "~/features/badges/BadgeRepository.server";
+import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
import { logger } from "~/utils/logger";
import {
errorToastIfFalsy,
@@ -36,6 +37,7 @@ export const action = async ({ params }: ActionFunctionArgs) => {
await XRankPlacementRepository.unlinkPlayerByUserId(user.id);
await BadgeRepository.syncXPBadges();
+ await TrophyRepository.syncSpecialTrophies();
await XRankPlacementRepository.refreshTenStarWeapons(user.id);
return successToast("Unlink successful");
diff --git a/app/features/tournament-bracket/actions/to.$id.brackets.finalize.server.ts b/app/features/tournament-bracket/actions/to.$id.brackets.finalize.server.ts
index 8c0b09cf5..00b428d93 100644
--- a/app/features/tournament-bracket/actions/to.$id.brackets.finalize.server.ts
+++ b/app/features/tournament-bracket/actions/to.$id.brackets.finalize.server.ts
@@ -22,8 +22,12 @@ import {
import {
finalizeTournamentActionSchema,
type TournamentBadgeReceivers,
+ type TournamentTrophyReceiver,
} from "~/features/tournament-bracket/tournament-bracket-schemas.server";
-import { validateBadgeReceivers } from "~/features/tournament-bracket/tournament-bracket-utils";
+import {
+ validateBadgeReceivers,
+ validateTrophyReceiver,
+} from "~/features/tournament-bracket/tournament-bracket-utils";
import * as TournamentMatchRepository from "~/features/tournament-match/TournamentMatchRepository.server";
import { refreshTentativeTiersCache } from "~/features/tournament-organization/core/tentativeTiers.server";
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
@@ -53,8 +57,18 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
errorToastIfFalsy(tournament.canFinalize(user), "Can't finalize tournament");
+ const event = await CalendarRepository.findById(tournament.ctx.eventId, {
+ includeBadgePrizes: true,
+ includeTrophy: true,
+ });
+ invariant(event, "Event not found for tournament");
+
const badgeOwnersValid = data.badgeReceivers
- ? await requireValidBadgeReceivers(data.badgeReceivers, tournament)
+ ? requireValidBadgeReceivers({
+ badgeReceivers: data.badgeReceivers,
+ badges: event.badgePrizes ?? [],
+ tournament,
+ })
: true;
if (!badgeOwnersValid) errorToast("New badge owners invalid");
@@ -68,6 +82,17 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
const standingsResult = Standings.tournamentStandings(tournament);
const finalStandings = Standings.flattenStandings(standingsResult);
+ const trophyReceiver = event.trophy ? (data.trophyReceiver ?? null) : null;
+ if (event.trophy) {
+ const trophyReceiverValid = requireValidTrophyReceiver({
+ trophyReceiver,
+ trophy: event.trophy,
+ finalStandings,
+ tournament,
+ });
+ if (!trophyReceiverValid) errorToast("Invalid trophy receiver");
+ }
+
const calculateSeasonalStats =
tournament.ranked && typeof season === "number";
const ratingTargets = summaryRatingTargets(results);
@@ -103,6 +128,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
summary,
season,
badgeReceivers: data.badgeReceivers ?? undefined,
+ trophyReceiver: trophyReceiver ?? undefined,
});
} else {
logger.info(
@@ -133,6 +159,12 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
notifyBadgeReceivers(data.badgeReceivers);
}
+ if (trophyReceiver) {
+ logger.info(
+ `Trophy receiver for tournament id ${tournamentId}: ${JSON.stringify(trophyReceiver)}`,
+ );
+ }
+
clearTournamentDataCache(tournamentId);
// ensure RunningTournament = sidebar updates
@@ -144,17 +176,15 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
});
};
-async function requireValidBadgeReceivers(
- badgeReceivers: TournamentBadgeReceivers,
- tournament: Tournament,
-) {
- const badges = (
- await CalendarRepository.findById(tournament.ctx.eventId, {
- includeBadgePrizes: true,
- })
- )?.badgePrizes;
- invariant(badges, "validateBadgeOwners: Event with badge prizes not found");
-
+function requireValidBadgeReceivers({
+ badgeReceivers,
+ badges,
+ tournament,
+}: {
+ badgeReceivers: TournamentBadgeReceivers;
+ badges: ReadonlyArray<{ id: number }>;
+ tournament: Tournament;
+}) {
const error = validateBadgeReceivers({
badgeReceivers,
badges,
@@ -170,6 +200,57 @@ async function requireValidBadgeReceivers(
return true;
}
+function requireValidTrophyReceiver({
+ trophyReceiver,
+ trophy,
+ finalStandings,
+ tournament,
+}: {
+ trophyReceiver: TournamentTrophyReceiver | null;
+ trophy: { id: number };
+ finalStandings: Array<{
+ placement: number;
+ team: { members: Array<{ userId: number }> };
+ }>;
+ tournament: Tournament;
+}) {
+ const error = validateTrophyReceiver({ trophyReceiver, trophy });
+ if (error) {
+ logger.warn(
+ `validateTrophyReceiver: Invalid trophy receiver for tournament ${tournament.ctx.id}: ${error}`,
+ );
+ return false;
+ }
+
+ if (!trophyReceiver) return true;
+
+ const firstPlace = finalStandings.find(
+ (standing) => standing.placement === 1,
+ );
+ if (!firstPlace) {
+ logger.warn(
+ `validateTrophyReceiver: No 1st place standing for tournament ${tournament.ctx.id}`,
+ );
+ return false;
+ }
+
+ const firstPlaceUserIds = new Set(
+ firstPlace.team.members.map((m) => m.userId),
+ );
+ const invalidUserId = trophyReceiver.userIds.find(
+ (userId) => !firstPlaceUserIds.has(userId),
+ );
+
+ if (invalidUserId !== undefined) {
+ logger.warn(
+ `validateTrophyReceiver: User ${invalidUserId} not in 1st place team for tournament ${tournament.ctx.id}`,
+ );
+ return false;
+ }
+
+ return true;
+}
+
async function notifyBadgeReceivers(badgeReceivers: TournamentBadgeReceivers) {
try {
for (const receiver of badgeReceivers) {
diff --git a/app/features/tournament-bracket/loaders/to.$id.brackets.finalize.server.ts b/app/features/tournament-bracket/loaders/to.$id.brackets.finalize.server.ts
index d2b378964..a25146279 100644
--- a/app/features/tournament-bracket/loaders/to.$id.brackets.finalize.server.ts
+++ b/app/features/tournament-bracket/loaders/to.$id.brackets.finalize.server.ts
@@ -34,19 +34,21 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
);
}
- const badges = (
- await CalendarRepository.findById(tournament.ctx.eventId, {
- includeBadgePrizes: true,
- })
- )?.badgePrizes?.sort((a, b) => a.id - b.id);
+ const event = await CalendarRepository.findById(tournament.ctx.eventId, {
+ includeBadgePrizes: true,
+ includeTrophy: true,
+ });
invariant(
- badges,
+ event?.badgePrizes,
`Tournament ${tournament.ctx.id} event not found for badges`,
);
+ const badges = event.badgePrizes.sort((a, b) => a.id - b.id);
+
return {
badges,
+ trophy: event.trophy,
standings: await standingsWithSetParticipation(tournament),
};
};
diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.finalize.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.finalize.tsx
index dd92e37c6..53fbf3bd7 100644
--- a/app/features/tournament-bracket/routes/to.$id.brackets.finalize.tsx
+++ b/app/features/tournament-bracket/routes/to.$id.brackets.finalize.tsx
@@ -10,8 +10,15 @@ import { FormMessage } from "~/components/FormMessage";
import { Placement } from "~/components/Placement";
import { SubmitButton } from "~/components/SubmitButton";
import { useTournament } from "~/features/tournament/routes/to.$id";
-import type { TournamentBadgeReceivers } from "~/features/tournament-bracket/tournament-bracket-schemas.server";
-import { validateBadgeReceivers } from "~/features/tournament-bracket/tournament-bracket-utils";
+import type {
+ TournamentBadgeReceivers,
+ TournamentTrophyReceiver,
+} from "~/features/tournament-bracket/tournament-bracket-schemas.server";
+import {
+ validateBadgeReceivers,
+ validateTrophyReceiver,
+} from "~/features/tournament-bracket/tournament-bracket-utils";
+import { Trophy } from "~/features/trophies/components/Trophy";
import { ParticipationPill } from "~/features/user-page/components/ParticipationPill";
import invariant from "~/utils/invariant";
import { action } from "../actions/to.$id.brackets.finalize.server";
@@ -26,19 +33,48 @@ export default function TournamentFinalizePage() {
const data = useLoaderData();
const { t } = useTranslation(["tournament"]);
const location = useLocation();
- const [isAssignLaterSelected, setIsAssignLaterSelected] =
+ const tournament = useTournament();
+ const firstPlaceStanding = data.standings.find(
+ (standing) => standing.placement === 1,
+ );
+ const trophyDefaultUserIds =
+ data.trophy && firstPlaceStanding
+ ? tournament.minMembersPerTeam === firstPlaceStanding.members.length
+ ? firstPlaceStanding.members.map((m) => m.userId)
+ : []
+ : [];
+
+ const [isAssignBadgesLaterSelected, setIsAssignBadgesLaterSelected] =
React.useState(false);
const [badgeReceivers, setBadgeReceivers] =
React.useState([]);
+ const [trophyReceiverUserIds, setTrophyReceiverUserIds] =
+ React.useState>(trophyDefaultUserIds);
const bracketUrl = location.pathname.replace(/\/finalize$/, "");
const tournamentHasBadges = data.badges.length > 0;
+ const tournamentHasTrophy = Boolean(data.trophy);
- const badgesError = !isAssignLaterSelected
- ? validateBadgeReceivers({ badgeReceivers, badges: data.badges })
+ const trophyReceiver: TournamentTrophyReceiver | null =
+ data.trophy && firstPlaceStanding
+ ? { trophyId: data.trophy.id, userIds: trophyReceiverUserIds }
+ : null;
+
+ const badgesError =
+ !isAssignBadgesLaterSelected && !tournamentHasTrophy
+ ? validateBadgeReceivers({ badgeReceivers, badges: data.badges })
+ : null;
+
+ const trophyError = tournamentHasTrophy
+ ? validateTrophyReceiver({
+ trophyReceiver,
+ trophy: data.trophy ?? null,
+ })
: null;
+ const error = badgesError ?? trophyError;
+
return (
- {tournamentHasBadges ? (
+ {tournamentHasTrophy && data.trophy && firstPlaceStanding ? (
+ <>
+
+
+ >
+ ) : tournamentHasBadges ? (
<>
{t("tournament:actions.finalize.assignBadgesLater")}
- {!isAssignLaterSelected ? (
+ {!isAssignBadgesLaterSelected ? (
<>
;
+ error:
+ | ReturnType
+ | ReturnType;
isAssigningBadges: boolean;
}) {
const fetcher = useFetcher();
@@ -248,3 +304,52 @@ function NewBadgeReceiversSelector({
);
}
+
+function NewTrophyReceiversSelector({
+ trophy,
+ firstPlaceStanding,
+ trophyReceiverUserIds,
+ setTrophyReceiverUserIds,
+}: {
+ trophy: NonNullable
;
+ firstPlaceStanding: FinalizeTournamentLoaderData["standings"][number];
+ trophyReceiverUserIds: Array;
+ setTrophyReceiverUserIds: (userIds: Array) => void;
+}) {
+ const handleReceiverSelected = (userId: number) => (isSelected: boolean) => {
+ if (isSelected) {
+ setTrophyReceiverUserIds([...trophyReceiverUserIds, userId]);
+ } else {
+ setTrophyReceiverUserIds(
+ trophyReceiverUserIds.filter((id) => id !== userId),
+ );
+ }
+ };
+
+ return (
+
+
+
+ {firstPlaceStanding.members.map((member, i) => {
+ return (
+
+
+
+
+ {member.username}
+
+
+ {i !== firstPlaceStanding.members.length - 1 ? (
+
+ ) : null}
+
+ );
+ })}
+
+ );
+}
diff --git a/app/features/tournament-bracket/tournament-bracket-schemas.server.ts b/app/features/tournament-bracket/tournament-bracket-schemas.server.ts
index ddc70e3f4..53f586390 100644
--- a/app/features/tournament-bracket/tournament-bracket-schemas.server.ts
+++ b/app/features/tournament-bracket/tournament-bracket-schemas.server.ts
@@ -183,6 +183,14 @@ const badgeReceivers = z.array(
}),
);
+export type TournamentTrophyReceiver = z.infer;
+
+const trophyReceiver = z.object({
+ trophyId: id,
+ userIds: z.array(id).min(1).max(50),
+});
+
export const finalizeTournamentActionSchema = z.object({
badgeReceivers: z.preprocess(safeJSONParse, badgeReceivers.nullish()),
+ trophyReceiver: z.preprocess(safeJSONParse, trophyReceiver.nullish()),
});
diff --git a/app/features/tournament-bracket/tournament-bracket-utils.ts b/app/features/tournament-bracket/tournament-bracket-utils.ts
index 77b009818..16c9695fd 100644
--- a/app/features/tournament-bracket/tournament-bracket-utils.ts
+++ b/app/features/tournament-bracket/tournament-bracket-utils.ts
@@ -1,4 +1,7 @@
-import type { TournamentBadgeReceivers } from "~/features/tournament-bracket/tournament-bracket-schemas.server";
+import type {
+ TournamentBadgeReceivers,
+ TournamentTrophyReceiver,
+} from "~/features/tournament-bracket/tournament-bracket-schemas.server";
import type { TournamentLoaderData } from "../tournament/loaders/to.$id.server";
import type { Standing } from "./core/Bracket";
@@ -106,3 +109,23 @@ export function validateBadgeReceivers({
return null;
}
+
+export function validateTrophyReceiver({
+ trophyReceiver,
+ trophy,
+}: {
+ trophyReceiver: TournamentTrophyReceiver | null;
+ trophy: { id: number } | null;
+}) {
+ if (!trophy) return null;
+
+ if (!trophyReceiver || trophyReceiver.trophyId !== trophy.id) {
+ return "TROPHY_NOT_FOUND";
+ }
+
+ if (trophyReceiver.userIds.length === 0) {
+ return "TROPHY_NOT_ASSIGNED";
+ }
+
+ return null;
+}
diff --git a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts
index 327afb435..38ef6979b 100644
--- a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts
+++ b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts
@@ -213,6 +213,26 @@ export function searchByName({
.execute();
}
+export function findOneById(id: number) {
+ return db
+ .selectFrom("TournamentOrganization")
+ .leftJoin(
+ "UserSubmittedImage",
+ "UserSubmittedImage.id",
+ "TournamentOrganization.avatarImgId",
+ )
+ .select(({ eb }) => [
+ "TournamentOrganization.id",
+ "TournamentOrganization.name",
+ "TournamentOrganization.slug",
+ concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
+ "avatarUrl",
+ ),
+ ])
+ .where("TournamentOrganization.id", "=", id)
+ .executeTakeFirst();
+}
+
interface FindEventsByMonthArgs {
month: number;
year: number;
diff --git a/app/features/tournament-organization/loaders/org.$slug.server.ts b/app/features/tournament-organization/loaders/org.$slug.server.ts
index e46bd3da8..82de97f38 100644
--- a/app/features/tournament-organization/loaders/org.$slug.server.ts
+++ b/app/features/tournament-organization/loaders/org.$slug.server.ts
@@ -2,6 +2,8 @@ import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import { getUser } from "~/features/auth/core/user.server";
import { calculateTentativeTier } from "~/features/tournament/core/tiering";
+import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
+import { canAccessTrophies } from "~/features/trophies/trophies-utils";
import type { SerializeFrom } from "~/utils/remix";
import { parseSafeSearchParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
@@ -80,6 +82,9 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
series: await seriesInfo(),
month,
year,
+ trophies: canAccessTrophies(user)
+ ? await TrophyRepository.findByOrganizationId(organization.id)
+ : [],
bannedUsers:
user?.id && organization.permissions.BAN.includes(user.id)
? await TournamentOrganizationRepository.findAllBannedUsersByOrganizationId(
diff --git a/app/features/tournament-organization/routes/org.$slug.tsx b/app/features/tournament-organization/routes/org.$slug.tsx
index fef94a9fe..8e58aba0a 100644
--- a/app/features/tournament-organization/routes/org.$slug.tsx
+++ b/app/features/tournament-organization/routes/org.$slug.tsx
@@ -6,9 +6,10 @@ import {
SquarePen,
Users,
} from "lucide-react";
+import * as React from "react";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
-import { Link, useLoaderData, useSearchParams } from "react-router";
+import { Link, useFetcher, useLoaderData, useSearchParams } from "react-router";
import { Avatar } from "~/components/Avatar";
import { Divider } from "~/components/Divider";
import { LinkButton, SendouButton } from "~/components/elements/Button";
@@ -29,6 +30,16 @@ import { TierPill } from "~/components/TierPill";
import { useUser } from "~/features/auth/core/user";
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
import { BannedUsersList } from "~/features/tournament-organization/components/BannedPlayersList";
+import {
+ Trophy,
+ TrophyContextProvider,
+ TrophyGrid,
+ TrophyPlaceholder,
+} from "~/features/trophies/components/Trophy";
+import { TrophyShowcaseModal } from "~/features/trophies/components/TrophyShowcase";
+import { TrophyTournamentHistory } from "~/features/trophies/components/TrophyTournamentHistory";
+import type { TrophyTournamentsLoaderData } from "~/features/trophies/routes/trophies.$id.tournaments";
+import { useProgressiveRender } from "~/features/trophies/trophies-utils";
import { SendouForm } from "~/form/SendouForm";
import { useHasPermission, useHasRole } from "~/modules/permissions/hooks";
import { databaseTimestampNow, databaseTimestampToDate } from "~/utils/dates";
@@ -42,6 +53,7 @@ import {
tournamentOrganizationPage,
tournamentOrganizationStatsPage,
tournamentPage,
+ trophyTournamentsPage,
userPage,
} from "~/utils/urls";
import { action } from "../actions/org.$slug.server";
@@ -71,7 +83,7 @@ export const meta: MetaFunction = (args) => {
};
export const handle: SendouRouteHandle = {
- i18n: ["badges", "org"],
+ i18n: ["badges", "org", "trophies"],
breadcrumb: ({ match }) => {
const data = match.loaderData as SerializeFrom | undefined;
@@ -209,7 +221,7 @@ function LogoHeader() {
}
function InfoTabs() {
- const { t } = useTranslation(["org"]);
+ const { t } = useTranslation(["org", "trophies"]);
const data = useLoaderData();
const isAdmin = useHasRole("ADMIN");
const canBanPlayers = useHasPermission(data.organization, "BAN");
@@ -217,6 +229,8 @@ function InfoTabs() {
const hasSocials =
data.organization.socials && data.organization.socials.length > 0;
const hasBadges = data.organization.badges.length > 0;
+ const hasTrophies = data.trophies.length > 0;
+ const hasRewards = hasBadges || hasTrophies;
return (
@@ -229,11 +243,11 @@ function InfoTabs() {
{t("org:edit.form.members.title")}
}
+ id="rewards"
+ isDisabled={!hasRewards}
+ icon={
}
>
- {t("org:edit.form.badges.title")}
+ {t("org:edit.form.rewards.title")}
{canBanPlayers && data.bannedUsers ? (
-
-
+
+
{data.bannedUsers ? (
@@ -274,6 +291,37 @@ function InfoTabs() {
);
}
+function RewardsPanel({
+ badges,
+ trophies,
+}: {
+ badges: SerializeFrom["organization"]["badges"];
+ trophies: SerializeFrom["trophies"];
+}) {
+ const { t } = useTranslation(["org", "trophies"]);
+
+ return (
+
+ {trophies.length > 0 ? (
+ <>
+
+ {t("trophies:title")}
+
+
+ >
+ ) : null}
+ {badges.length > 0 ? (
+ <>
+
+ {t("org:edit.form.badges.title")}
+
+
+ >
+ ) : null}
+
+ );
+}
+
function AdminControls() {
const data = useLoaderData();
@@ -692,3 +740,74 @@ function EventLeaderboardRow({
);
}
+
+function RewardsTrophyGrid({
+ trophies,
+}: {
+ trophies: SerializeFrom["trophies"];
+}) {
+ const visibleCount = useProgressiveRender(trophies.length, "");
+ const [openTrophy, setOpenTrophy] = React.useState<
+ SerializeFrom["trophies"][number] | null
+ >(null);
+
+ return (
+
+
+ {trophies.map((trophy, i) =>
+ i < visibleCount ? (
+ setOpenTrophy(trophy)}
+ aria-label={trophy.name}
+ >
+
+
+ ) : (
+
+ ),
+ )}
+
+ {openTrophy ? (
+ setOpenTrophy(null)}
+ >
+
+
+ ) : null}
+
+ );
+}
+
+function TrophyModalTournaments({ trophyId }: { trophyId: number }) {
+ const { t } = useTranslation(["trophies"]);
+ const fetcher = useFetcher();
+
+ const loadedRef = React.useRef(false);
+ React.useEffect(() => {
+ if (loadedRef.current) return;
+ loadedRef.current = true;
+ fetcher.load(trophyTournamentsPage(trophyId));
+ }, [fetcher.load, trophyId]);
+
+ if (!fetcher.data || fetcher.data.tournaments.length === 0) return null;
+
+ return (
+
+
{t("trophies:details.tournamentHistory")}
+
+
+ );
+}
diff --git a/app/features/tournament-organization/tournament-organization.module.css b/app/features/tournament-organization/tournament-organization.module.css
index 38e195a78..845bd7e0c 100644
--- a/app/features/tournament-organization/tournament-organization.module.css
+++ b/app/features/tournament-organization/tournament-organization.module.css
@@ -200,3 +200,23 @@
fill: #1285fe;
}
}
+
+.trophyGridButton {
+ all: unset;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.trophyModalTournaments {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-2);
+ opacity: 1;
+ transition: opacity 0.2s ease;
+
+ @starting-style {
+ opacity: 0;
+ }
+}
diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts
index cb9316df3..8dc787058 100644
--- a/app/features/tournament/TournamentRepository.server.ts
+++ b/app/features/tournament/TournamentRepository.server.ts
@@ -13,7 +13,10 @@ import { actorId } from "~/features/auth/core/user.server";
import { identifierToUserIds } from "~/features/mmr/mmr-utils";
import * as Progression from "~/features/tournament-bracket/core/Progression";
import type { TournamentSummary } from "~/features/tournament-bracket/core/summarizer.server";
-import type { TournamentBadgeReceivers } from "~/features/tournament-bracket/tournament-bracket-schemas.server";
+import type {
+ TournamentBadgeReceivers,
+ TournamentTrophyReceiver,
+} from "~/features/tournament-bracket/tournament-bracket-schemas.server";
import { modesShort } from "~/modules/in-game-lists/modes";
import { nullFilledArray, nullifyingAvg } from "~/utils/arrays";
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
@@ -1078,11 +1081,13 @@ export function finalize({
summary,
season,
badgeReceivers = [],
+ trophyReceiver,
}: {
tournamentId: number;
summary: TournamentSummary;
season?: number;
badgeReceivers?: TournamentBadgeReceivers;
+ trophyReceiver?: TournamentTrophyReceiver;
}) {
const seasonValue = season ?? null;
@@ -1245,6 +1250,29 @@ export function finalize({
.execute();
}
+ if (trophyReceiver && trophyReceiver.userIds.length > 0) {
+ const tournamentRow = await trx
+ .selectFrom("Tournament")
+ .select("tier")
+ .where("id", "=", tournamentId)
+ .executeTakeFirst();
+
+ await trx
+ .insertInto("TrophyOwner")
+ .values(
+ trophyReceiver.userIds.map((userId) => ({
+ tournamentId,
+ trophyId: trophyReceiver.trophyId,
+ userId,
+ tier: tournamentRow?.tier ?? null,
+ })),
+ )
+ .onConflict((oc) =>
+ oc.columns(["tournamentId", "userId", "trophyId"]).doNothing(),
+ )
+ .execute();
+ }
+
const tournamentResults = summary.tournamentResults
.map((tournamentResult) => ({
tournamentResult,
diff --git a/app/features/trophies/TrophyRepository.server.test.ts b/app/features/trophies/TrophyRepository.server.test.ts
new file mode 100644
index 000000000..ded5ed322
--- /dev/null
+++ b/app/features/trophies/TrophyRepository.server.test.ts
@@ -0,0 +1,295 @@
+import { afterEach, beforeEach, describe, expect, test } from "vitest";
+import { db } from "~/db/sql";
+import type { TournamentTierNumber } from "~/features/tournament/core/tiering";
+import { dateToDatabaseTimestamp } from "~/utils/dates";
+import { dbInsertUsers, dbReset } from "~/utils/Test";
+import * as TrophyRepository from "./TrophyRepository.server";
+
+describe("trophy approvals", () => {
+ let pendingTrophyId: number;
+
+ beforeEach(async () => {
+ await dbInsertUsers(4);
+ await db
+ .insertInto("TournamentOrganization")
+ .values({ name: "Test Org", slug: "test-org" })
+ .execute();
+
+ const pending = await TrophyRepository.createPending({
+ name: "Test Trophy",
+ model: "model",
+ description: "",
+ organizationId: 1,
+ submitterUserId: 1,
+ });
+ pendingTrophyId = pending.id;
+ });
+
+ afterEach(() => dbReset());
+
+ test("creates the trophy exactly once when approvals exceed the required count", async () => {
+ expect(
+ await TrophyRepository.addApproval({ pendingTrophyId, userId: 2 }),
+ ).toBe(null);
+
+ const accepted = await TrophyRepository.addApproval({
+ pendingTrophyId,
+ userId: 3,
+ });
+ expect(accepted?.id).toBeTypeOf("number");
+
+ expect(
+ await TrophyRepository.addApproval({ pendingTrophyId, userId: 4 }),
+ ).toBe(null);
+
+ expect(await trophyCount()).toBe(1);
+ });
+
+ test("ignores repeated approvals from the same user", async () => {
+ await TrophyRepository.addApproval({ pendingTrophyId, userId: 2 });
+
+ expect(
+ await TrophyRepository.addApproval({ pendingTrophyId, userId: 2 }),
+ ).toBe(null);
+
+ const pending = await TrophyRepository.findPendingById(pendingTrophyId);
+ expect(pending?.approvals.length).toBe(1);
+ expect(await trophyCount()).toBe(0);
+ });
+
+ test("re-approval after acceptance does not create another trophy", async () => {
+ await TrophyRepository.addApproval({ pendingTrophyId, userId: 2 });
+ await TrophyRepository.addApproval({ pendingTrophyId, userId: 3 });
+
+ expect(
+ await TrophyRepository.addApproval({ pendingTrophyId, userId: 2 }),
+ ).toBe(null);
+
+ expect(await trophyCount()).toBe(1);
+ });
+
+ test("declines a pending trophy that is not accepted", async () => {
+ await TrophyRepository.addApproval({ pendingTrophyId, userId: 2 });
+
+ expect(
+ await TrophyRepository.declinePending({
+ id: pendingTrophyId,
+ reason: "reason",
+ declinedByUserId: 3,
+ }),
+ ).toBe(true);
+
+ const pending = await TrophyRepository.findPendingById(pendingTrophyId);
+ expect(pending?.declinedAt).not.toBe(null);
+ expect(pending?.approvals.length).toBe(0);
+ });
+
+ test("does not decline an already accepted pending trophy", async () => {
+ await TrophyRepository.addApproval({ pendingTrophyId, userId: 2 });
+ await TrophyRepository.addApproval({ pendingTrophyId, userId: 3 });
+
+ expect(
+ await TrophyRepository.declinePending({
+ id: pendingTrophyId,
+ reason: "reason",
+ declinedByUserId: 4,
+ }),
+ ).toBe(false);
+
+ const pending = await TrophyRepository.findPendingById(pendingTrophyId);
+ expect(pending?.declinedAt).toBe(null);
+ expect(await trophyCount()).toBe(1);
+ });
+
+ test("approvals after a decline do not create a trophy", async () => {
+ await TrophyRepository.declinePending({
+ id: pendingTrophyId,
+ reason: "reason",
+ declinedByUserId: 2,
+ });
+
+ await TrophyRepository.addApproval({ pendingTrophyId, userId: 2 });
+ expect(
+ await TrophyRepository.addApproval({ pendingTrophyId, userId: 3 }),
+ ).toBe(null);
+
+ expect(await trophyCount()).toBe(0);
+ });
+});
+
+describe("trophy list tiers", () => {
+ let trophyId: number;
+
+ beforeEach(async () => {
+ await dbInsertUsers(2);
+
+ const trophy = await db
+ .insertInto("Trophy")
+ .values({
+ name: "Tiered Trophy",
+ model: "model",
+ creatorId: 1,
+ managerId: 1,
+ })
+ .returning("id")
+ .executeTakeFirstOrThrow();
+ trophyId = trophy.id;
+ });
+
+ afterEach(() => dbReset());
+
+ test("an upcoming tournament without tier info does not hide the earned tier", async () => {
+ await insertTrophyTournament({ trophyId, tier: 3, startInDays: -21 });
+ await insertTrophyTournament({ trophyId, tier: null, startInDays: 10 });
+
+ const trophy = (await TrophyRepository.all()).find(
+ (row) => row.name === "Tiered Trophy",
+ );
+
+ expect(trophy?.tier).toBe(3);
+ });
+
+ test("uses the most recent tier when multiple tournaments have one", async () => {
+ await insertTrophyTournament({ trophyId, tier: 5, startInDays: -30 });
+ await insertTrophyTournament({ trophyId, tier: 3, startInDays: -7 });
+
+ const trophy = (await TrophyRepository.all()).find(
+ (row) => row.name === "Tiered Trophy",
+ );
+
+ expect(trophy?.tier).toBe(3);
+ });
+
+ test("has no tier when no linked tournament has tier info", async () => {
+ await insertTrophyTournament({ trophyId, tier: null, startInDays: 10 });
+
+ const trophy = (await TrophyRepository.all()).find(
+ (row) => row.name === "Tiered Trophy",
+ );
+
+ expect(trophy?.tier).toBe(null);
+ expect(trophy?.tentativeTier).toBe(null);
+ });
+
+ test("returns the start time of the next upcoming tournament", async () => {
+ await insertTrophyTournament({ trophyId, tier: 3, startInDays: -21 });
+ await insertTrophyTournament({ trophyId, tier: null, startInDays: 20 });
+ await insertTrophyTournament({ trophyId, tier: null, startInDays: 10 });
+
+ const trophy = (await TrophyRepository.all()).find(
+ (row) => row.name === "Tiered Trophy",
+ );
+
+ const expected = dateToDatabaseTimestamp(
+ new Date(Date.now() + 10 * 24 * 60 * 60 * 1000),
+ );
+ expect(
+ Math.abs((trophy?.upcomingTournamentAt ?? 0) - expected),
+ ).toBeLessThan(10);
+ });
+
+ test("sorts trophies with an upcoming tournament first within the same tier", async () => {
+ await insertTrophyTournament({ trophyId, tier: 3, startInDays: -21 });
+
+ const upcoming = await db
+ .insertInto("Trophy")
+ .values({
+ name: "Upcoming Trophy",
+ model: "model",
+ creatorId: 1,
+ managerId: 1,
+ })
+ .returning("id")
+ .executeTakeFirstOrThrow();
+ await insertTrophyTournament({
+ trophyId: upcoming.id,
+ tier: 3,
+ startInDays: -14,
+ });
+ await insertTrophyTournament({
+ trophyId: upcoming.id,
+ tier: null,
+ startInDays: 10,
+ });
+
+ const distant = await db
+ .insertInto("Trophy")
+ .values({
+ name: "Distant Trophy",
+ model: "model",
+ creatorId: 1,
+ managerId: 1,
+ })
+ .returning("id")
+ .executeTakeFirstOrThrow();
+ await insertTrophyTournament({
+ trophyId: distant.id,
+ tier: 3,
+ startInDays: -7,
+ });
+ await insertTrophyTournament({
+ trophyId: distant.id,
+ tier: null,
+ startInDays: 5 * 7,
+ });
+
+ const names = (await TrophyRepository.all()).map((row) => row.name);
+
+ expect(names).toEqual([
+ "Upcoming Trophy",
+ "Tiered Trophy",
+ "Distant Trophy",
+ ]);
+ });
+});
+
+async function trophyCount() {
+ const { count } = await db
+ .selectFrom("Trophy")
+ .select((eb) => eb.fn.countAll().as("count"))
+ .executeTakeFirstOrThrow();
+
+ return count;
+}
+
+async function insertTrophyTournament({
+ trophyId,
+ tier,
+ startInDays,
+}: {
+ trophyId: number;
+ tier: TournamentTierNumber | null;
+ startInDays: number;
+}) {
+ const tournament = await db
+ .insertInto("Tournament")
+ .values({
+ mapPickingStyle: "AUTO_ALL",
+ settings: JSON.stringify({ bracketProgression: [] }),
+ tier,
+ })
+ .returning("id")
+ .executeTakeFirstOrThrow();
+
+ const event = await db
+ .insertInto("CalendarEvent")
+ .values({
+ name: `Tournament ${tournament.id}`,
+ bracketUrl: "https://example.com",
+ authorId: 1,
+ tournamentId: tournament.id,
+ trophyId,
+ })
+ .returning("id")
+ .executeTakeFirstOrThrow();
+
+ await db
+ .insertInto("CalendarEventDate")
+ .values({
+ eventId: event.id,
+ startsAt: dateToDatabaseTimestamp(
+ new Date(Date.now() + startInDays * 24 * 60 * 60 * 1000),
+ ),
+ })
+ .execute();
+}
diff --git a/app/features/trophies/TrophyRepository.server.ts b/app/features/trophies/TrophyRepository.server.ts
new file mode 100644
index 000000000..e99bc2aa9
--- /dev/null
+++ b/app/features/trophies/TrophyRepository.server.ts
@@ -0,0 +1,883 @@
+import { sub } from "date-fns";
+import type { ExpressionBuilder, NotNull, Transaction } from "kysely";
+import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
+import * as R from "remeda";
+import { db } from "~/db/sql";
+import type { DB } from "~/db/tables";
+import { isSupporter } from "~/modules/permissions/utils";
+import {
+ databaseTimestampToDate,
+ dateToDatabaseTimestamp,
+} from "~/utils/dates";
+import {
+ calendarEventStartTime,
+ commonUserSelect,
+ peakXpOverallSql,
+ tournamentLogoWithDefault,
+ tournamentTeamCount,
+} from "~/utils/kysely.server";
+import { getTentativeTier } from "../tournament-organization/core/tentativeTiers.server";
+import { sortTrophiesByFavorites } from "../user-page/core/trophy-sorting.server";
+import {
+ SUPPORTER_TROPHY_CODE,
+ TROPHY_APPROVALS_REQUIRED,
+ XP_TROPHY_CODE_PREFIX,
+} from "./trophies-constants";
+import {
+ hasUpcomingTournamentSoon,
+ parseSpecialTrophyCode,
+} from "./trophies-utils";
+
+type TrophyRecentTournament = {
+ tier: number | null;
+ name: string;
+ organizationId: number | null;
+ startTime: number | null;
+};
+
+export async function all() {
+ const rows = await db
+ .selectFrom("Trophy")
+ .select((eb) => ["id", "name", "model", withRecentTournaments(eb)])
+ .execute();
+
+ return sortByEffectiveTier(rows.map(addEffectiveTier));
+}
+
+const withRecentTournaments = (eb: ExpressionBuilder) =>
+ jsonArrayFrom(
+ eb
+ .selectFrom("CalendarEvent")
+ .innerJoin("Tournament", "Tournament.id", "CalendarEvent.tournamentId")
+ .select((eb2) => [
+ "Tournament.tier",
+ "CalendarEvent.name",
+ "CalendarEvent.organizationId",
+ calendarEventStartTime(eb2).as("startTime"),
+ ])
+ .whereRef("CalendarEvent.trophyId", "=", "Trophy.id")
+ .where("CalendarEvent.hidden", "=", 0)
+ .orderBy((eb2) => calendarEventStartTime(eb2), "desc"),
+ ).as("recentTournaments");
+
+function addEffectiveTier<
+ T extends { recentTournaments: Array },
+>({ recentTournaments, ...rest }: T) {
+ const upcomingTournamentAt = nextUpcomingStartTime(recentTournaments);
+
+ for (const tournament of recentTournaments) {
+ const tierInfo = tournamentTierInfo(tournament);
+ if (tierInfo.tier !== null || tierInfo.tentativeTier !== null) {
+ return { ...rest, ...tierInfo, upcomingTournamentAt };
+ }
+ }
+
+ return { ...rest, tier: null, tentativeTier: null, upcomingTournamentAt };
+}
+
+function nextUpcomingStartTime(tournaments: Array) {
+ const now = dateToDatabaseTimestamp(new Date());
+
+ // ordered newest first, so the last future start time is the next one up
+ const futureStartTimes = tournaments
+ .map((tournament) => tournament.startTime)
+ .filter(
+ (startTime): startTime is number => startTime !== null && startTime > now,
+ );
+
+ return futureStartTimes.at(-1) ?? null;
+}
+
+function tournamentTierInfo(tournament: TrophyRecentTournament) {
+ const isPastEvent =
+ tournament.startTime !== null &&
+ databaseTimestampToDate(tournament.startTime) <
+ sub(new Date(), { days: 1 });
+
+ const tentativeTier =
+ tournament.tier === null &&
+ tournament.organizationId !== null &&
+ !isPastEvent
+ ? getTentativeTier(tournament.organizationId, tournament.name)
+ : null;
+
+ return { tier: tournament.tier, tentativeTier };
+}
+
+function sortByEffectiveTier<
+ T extends {
+ id: number;
+ tier: number | null;
+ tentativeTier: number | null;
+ upcomingTournamentAt: number | null;
+ },
+>(rows: T[]) {
+ return R.sortBy(
+ rows,
+ (row) => row.tier ?? row.tentativeTier ?? Number.MAX_SAFE_INTEGER,
+ (row) => (hasUpcomingTournamentSoon(row.upcomingTournamentAt) ? 0 : 1),
+ (row) => row.id,
+ );
+}
+
+const withCreator = (eb: ExpressionBuilder) => {
+ return jsonObjectFrom(
+ eb
+ .selectFrom("User")
+ .select((eb) => commonUserSelect(eb))
+ .whereRef("User.id", "=", "Trophy.creatorId"),
+ ).as("creator");
+};
+
+const withManager = (eb: ExpressionBuilder) => {
+ return jsonObjectFrom(
+ eb
+ .selectFrom("User")
+ .select((eb) => commonUserSelect(eb))
+ .whereRef("User.id", "=", "Trophy.managerId"),
+ ).as("manager");
+};
+
+const withOrganization = (eb: ExpressionBuilder) => {
+ return jsonObjectFrom(
+ eb
+ .selectFrom("TournamentOrganization")
+ .select(["TournamentOrganization.name", "TournamentOrganization.slug"])
+ .whereRef("TournamentOrganization.id", "=", "Trophy.organizationId"),
+ ).as("organization");
+};
+
+const withOwners = (eb: ExpressionBuilder) => {
+ return jsonArrayFrom(
+ eb
+ .selectFrom("TrophyOwner")
+ .innerJoin("User", "TrophyOwner.userId", "User.id")
+ .select((eb) => [
+ eb.fn.count("TrophyOwner.trophyId").as("count"),
+ ...commonUserSelect(eb),
+ ])
+ .whereRef("TrophyOwner.trophyId", "=", "Trophy.id")
+ .groupBy("User.id")
+ .orderBy("count", "desc"),
+ ).as("owners");
+};
+
+const withSpecialOwners = (eb: ExpressionBuilder) => {
+ return jsonArrayFrom(
+ eb
+ .selectFrom("SpecialTrophyOwner")
+ .innerJoin("User", "SpecialTrophyOwner.userId", "User.id")
+ .select((eb) => [eb.val(1).as("count"), ...commonUserSelect(eb)])
+ .whereRef("SpecialTrophyOwner.trophyId", "=", "Trophy.id")
+ .orderBy("User.id", "asc"),
+ ).as("specialOwners");
+};
+
+export async function findByOrganizationId(organizationId: number) {
+ const rows = await db
+ .selectFrom("Trophy")
+ .select((eb) => ["id", "name", "model", withRecentTournaments(eb)])
+ .where("organizationId", "=", organizationId)
+ .execute();
+
+ return sortByEffectiveTier(rows.map(addEffectiveTier));
+}
+
+export async function findByOrganizationIds(organizationIds: number[]) {
+ if (organizationIds.length === 0) return [];
+
+ return db
+ .selectFrom("Trophy")
+ .select(["id", "name", "model", "organizationId"])
+ .where("organizationId", "in", organizationIds)
+ .execute();
+}
+
+async function findOwnedTrophies(userId: number) {
+ const tournamentRows = await db
+ .selectFrom("TrophyOwner")
+ .innerJoin("Trophy", "Trophy.id", "TrophyOwner.trophyId")
+ .innerJoin("User", "User.id", "TrophyOwner.userId")
+ .select(({ fn }) => [
+ fn.count("TrophyOwner.trophyId").as("count"),
+ fn.min("TrophyOwner.tier").as("tier"),
+ "Trophy.id",
+ "Trophy.name",
+ "Trophy.model",
+ "Trophy.code",
+ "User.favoriteTrophyIds",
+ "User.hiddenTrophyIds",
+ "User.patronTier",
+ ])
+ .where("TrophyOwner.userId", "=", userId)
+ .groupBy(["TrophyOwner.trophyId", "TrophyOwner.userId"])
+ .execute();
+
+ const specialRows = await db
+ .selectFrom("SpecialTrophyOwner")
+ .innerJoin("Trophy", "Trophy.id", "SpecialTrophyOwner.trophyId")
+ .innerJoin("User", "User.id", "SpecialTrophyOwner.userId")
+ .select([
+ "Trophy.id",
+ "Trophy.name",
+ "Trophy.model",
+ "Trophy.code",
+ "User.favoriteTrophyIds",
+ "User.hiddenTrophyIds",
+ "User.patronTier",
+ ])
+ .where("SpecialTrophyOwner.userId", "=", userId)
+ .execute();
+
+ return [
+ ...tournamentRows,
+ ...specialRows.map((row) => ({ ...row, count: 1, tier: null })),
+ ];
+}
+
+export async function findByOwnerUserId(userId: number) {
+ const rows = await findOwnedTrophies(userId);
+
+ if (rows.length === 0) return [];
+
+ const { favoriteTrophyIds, hiddenTrophyIds, patronTier } = rows[0];
+ const hiddenSet = new Set(hiddenTrophyIds ?? []);
+
+ return sortTrophiesByFavorites({
+ favoriteTrophyIds,
+ hiddenTrophyIds,
+ patronTier,
+ trophies: rows
+ .filter((row) => !hiddenSet.has(row.id))
+ .map((row) =>
+ R.omit(row, ["favoriteTrophyIds", "hiddenTrophyIds", "patronTier"]),
+ ),
+ }).trophies;
+}
+
+export async function findByOwnerUserIdIncludingHidden(userId: number) {
+ const rows = await findOwnedTrophies(userId);
+
+ return rows.map((row) =>
+ R.omit(row, ["favoriteTrophyIds", "hiddenTrophyIds", "patronTier"]),
+ );
+}
+
+export async function findById(trophyId: number) {
+ const row = await db
+ .selectFrom("Trophy")
+ .select((eb) => [
+ "Trophy.id",
+ "Trophy.name",
+ "Trophy.model",
+ "Trophy.code",
+ withCreator(eb),
+ withManager(eb),
+ withOrganization(eb),
+ withOwners(eb),
+ withSpecialOwners(eb),
+ ])
+ .where("Trophy.id", "=", trophyId)
+ .executeTakeFirst();
+
+ if (!row) return null;
+
+ const { specialOwners, ...trophy } = row;
+
+ return { ...trophy, owners: [...trophy.owners, ...specialOwners] };
+}
+
+export async function findTournamentsByTrophyId(trophyId: number) {
+ const rows = await db
+ .selectFrom("CalendarEvent")
+ .innerJoin("Tournament", "Tournament.id", "CalendarEvent.tournamentId")
+ .select((eb) => [
+ "Tournament.id as tournamentId",
+ "CalendarEvent.name",
+ "CalendarEvent.organizationId",
+ "Tournament.tier",
+ tournamentLogoWithDefault(eb).as("logoUrl"),
+ calendarEventStartTime(eb).as("startTime"),
+ tournamentTeamCount(eb).as("teamsCount"),
+ ])
+ .where("CalendarEvent.trophyId", "=", trophyId)
+ .where("CalendarEvent.hidden", "=", 0)
+ .orderBy("startTime", "desc")
+ .execute();
+
+ return rows.map((row) => ({
+ tournamentId: row.tournamentId,
+ name: row.name,
+ logoUrl: row.logoUrl,
+ startTime: row.startTime,
+ teamsCount: row.teamsCount,
+ ...tournamentTierInfo(row),
+ }));
+}
+
+export async function findWinsByOwner({
+ trophyId,
+ userId,
+}: {
+ trophyId: number;
+ userId: number;
+}) {
+ const tournaments = await db
+ .selectFrom("TrophyOwner")
+ .innerJoin("Tournament", "Tournament.id", "TrophyOwner.tournamentId")
+ .innerJoin("CalendarEvent", "CalendarEvent.tournamentId", "Tournament.id")
+ .select((eb) => [
+ "TrophyOwner.tournamentId",
+ "TrophyOwner.tier",
+ "CalendarEvent.name",
+ tournamentLogoWithDefault(eb).as("logoUrl"),
+ calendarEventStartTime(eb).as("startTime"),
+ tournamentTeamCount(eb).as("teamsCount"),
+ ])
+ .where("TrophyOwner.trophyId", "=", trophyId)
+ .where("TrophyOwner.userId", "=", userId)
+ .where("CalendarEvent.hidden", "=", 0)
+ .orderBy("startTime", "desc")
+ .execute();
+
+ if (tournaments.length === 0) return [];
+
+ const tournamentIds = tournaments.map(
+ (tournament) => tournament.tournamentId,
+ );
+
+ const ownResults = await db
+ .selectFrom("TournamentResult")
+ .select(["tournamentId", "tournamentTeamId"])
+ .where("userId", "=", userId)
+ .where("tournamentId", "in", tournamentIds)
+ .execute();
+
+ const teamIds = ownResults.map((result) => result.tournamentTeamId);
+
+ const memberRows =
+ teamIds.length > 0
+ ? await db
+ .selectFrom("TournamentResult")
+ .innerJoin("User", "User.id", "TournamentResult.userId")
+ .select((eb) => [
+ "TournamentResult.tournamentTeamId",
+ "TournamentResult.setResults",
+ ...commonUserSelect(eb),
+ ])
+ .where("TournamentResult.tournamentTeamId", "in", teamIds)
+ .execute()
+ : [];
+
+ const memberIds = R.unique(memberRows.map((member) => member.id));
+
+ const weaponRows =
+ memberIds.length > 0
+ ? await db
+ .selectFrom("ReportedWeapon")
+ .innerJoin(
+ "TournamentMatch",
+ "TournamentMatch.id",
+ "ReportedWeapon.tournamentMatchId",
+ )
+ .innerJoin(
+ "TournamentStage",
+ "TournamentStage.id",
+ "TournamentMatch.stageId",
+ )
+ .select(({ fn }) => [
+ "TournamentStage.tournamentId",
+ "ReportedWeapon.userId",
+ "ReportedWeapon.weaponSplId",
+ fn.countAll().as("count"),
+ ])
+ .where("TournamentStage.tournamentId", "in", tournamentIds)
+ .where("ReportedWeapon.userId", "in", memberIds)
+ .groupBy([
+ "TournamentStage.tournamentId",
+ "ReportedWeapon.userId",
+ "ReportedWeapon.weaponSplId",
+ ])
+ .orderBy("count", "desc")
+ .execute()
+ : [];
+
+ return tournaments.map((tournament) => {
+ const ownResult = ownResults.find(
+ (result) => result.tournamentId === tournament.tournamentId,
+ );
+
+ const members = memberRows
+ .filter(
+ (member) => member.tournamentTeamId === ownResult?.tournamentTeamId,
+ )
+ .map((member) => ({
+ ...R.omit(member, ["tournamentTeamId"]),
+ weapons: weaponRows
+ .filter(
+ (weapon) =>
+ weapon.tournamentId === tournament.tournamentId &&
+ weapon.userId === member.id,
+ )
+ .map((weapon) => weapon.weaponSplId),
+ }));
+
+ return {
+ ...tournament,
+ members: R.sortBy(
+ members,
+ (member) => (member.id === userId ? 0 : 1),
+ (member) => member.username.toLowerCase(),
+ ),
+ };
+ });
+}
+
+export async function findOrganizationIdById(trophyId: number) {
+ const row = await db
+ .selectFrom("Trophy")
+ .select("organizationId")
+ .where("id", "=", trophyId)
+ .executeTakeFirst();
+
+ return row?.organizationId ?? null;
+}
+
+export async function existsByName(args: {
+ name: string;
+ excludeTrophyId?: number;
+}) {
+ let trophyQuery = db
+ .selectFrom("Trophy")
+ .select("id")
+ .where("name", "=", args.name);
+
+ if (args.excludeTrophyId !== undefined) {
+ trophyQuery = trophyQuery.where("id", "!=", args.excludeTrophyId);
+ }
+
+ const trophy = await trophyQuery.executeTakeFirst();
+
+ if (trophy) return true;
+
+ let pendingQuery = db
+ .selectFrom("PendingTrophy")
+ .select("id")
+ .where("name", "=", args.name)
+ .where("declinedAt", "is", null);
+
+ if (args.excludeTrophyId !== undefined) {
+ pendingQuery = pendingQuery.where(
+ "targetTrophyId",
+ "is not",
+ args.excludeTrophyId,
+ );
+ }
+
+ const pending = await pendingQuery.executeTakeFirst();
+
+ return Boolean(pending);
+}
+
+export async function findManagedBy(userId: number) {
+ return db
+ .selectFrom("Trophy")
+ .select(["id", "name", "model", "organizationId", "managerId"])
+ .where("managerId", "=", userId)
+ .execute();
+}
+
+export async function findAllForEditing() {
+ return db
+ .selectFrom("Trophy")
+ .select(["id", "name", "model", "organizationId", "managerId"])
+ .where("code", "is", null)
+ .execute();
+}
+
+/**
+ * Recomputes ownership of every special trophy (supporter, XP).
+ * Existing owner rows that are still eligible keep their original `createdAt`.
+ */
+export function syncSpecialTrophies() {
+ return db.transaction().execute(async (trx) => {
+ await syncSupporterTrophyOwners(trx);
+ await syncXpTrophyOwners(trx);
+ });
+}
+
+async function syncSupporterTrophyOwners(trx: Transaction) {
+ const trophy = await trx
+ .selectFrom("Trophy")
+ .select("id")
+ .where("code", "=", SUPPORTER_TROPHY_CODE)
+ .executeTakeFirst();
+
+ if (!trophy) return;
+
+ const patrons = await trx
+ .selectFrom("User")
+ .select(["id", "patronTier"])
+ .where("patronTier", "is not", null)
+ .execute();
+
+ await replaceSpecialTrophyOwners({
+ trx,
+ trophyId: trophy.id,
+ userIds: patrons.filter(isSupporter).map((patron) => patron.id),
+ });
+}
+
+async function syncXpTrophyOwners(trx: Transaction) {
+ const xpTrophies = (
+ await trx
+ .selectFrom("Trophy")
+ .select(["id", "code"])
+ .where("code", "like", `${XP_TROPHY_CODE_PREFIX}%`)
+ .execute()
+ ).flatMap((trophy) => {
+ const parsed = parseSpecialTrophyCode(trophy.code);
+ return parsed?.type === "xp"
+ ? [{ id: trophy.id, value: parsed.value }]
+ : [];
+ });
+
+ if (xpTrophies.length === 0) return;
+
+ const byValueDesc = R.sortBy(xpTrophies, [(trophy) => trophy.value, "desc"]);
+
+ const userPeakXps = await trx
+ .selectFrom("SplatoonPlayer")
+ .select(["userId", peakXpOverallSql().as("peakXp")])
+ .where("userId", "is not", null)
+ .where("peakXp", "is not", null)
+ .$narrowType<{ userId: NotNull; peakXp: NotNull }>()
+ .execute();
+
+ const ownersByTrophyId = new Map(
+ xpTrophies.map((trophy) => [trophy.id, []]),
+ );
+ for (const { userId, peakXp } of userPeakXps) {
+ const highestReached = byValueDesc.find((trophy) => peakXp >= trophy.value);
+ if (!highestReached) continue;
+
+ ownersByTrophyId.get(highestReached.id)?.push(userId);
+ }
+
+ for (const [trophyId, userIds] of ownersByTrophyId) {
+ await replaceSpecialTrophyOwners({ trx, trophyId, userIds });
+ }
+}
+
+async function replaceSpecialTrophyOwners({
+ trx,
+ trophyId,
+ userIds,
+}: {
+ trx: Transaction;
+ trophyId: number;
+ userIds: number[];
+}) {
+ let deleteStale = trx
+ .deleteFrom("SpecialTrophyOwner")
+ .where("trophyId", "=", trophyId);
+ if (userIds.length > 0) {
+ deleteStale = deleteStale.where("userId", "not in", userIds);
+ }
+ await deleteStale.execute();
+
+ if (userIds.length === 0) return;
+
+ await trx
+ .insertInto("SpecialTrophyOwner")
+ .values(
+ userIds.map((userId) => ({
+ trophyId,
+ userId,
+ createdAt: dateToDatabaseTimestamp(new Date()),
+ })),
+ )
+ .onConflict((oc) => oc.doNothing())
+ .execute();
+}
+
+export async function createPending(args: {
+ name: string;
+ model: string;
+ description: string;
+ organizationId: number;
+ submitterUserId: number;
+ targetTrophyId?: number;
+ managerId?: number;
+}) {
+ return db
+ .insertInto("PendingTrophy")
+ .values({
+ name: args.name,
+ model: args.model,
+ description: args.description,
+ organizationId: args.organizationId,
+ submitterUserId: args.submitterUserId,
+ createdAt: dateToDatabaseTimestamp(new Date()),
+ declineReason: null,
+ declinedAt: null,
+ declinedByUserId: null,
+ targetTrophyId: args.targetTrophyId ?? null,
+ managerId: args.managerId ?? null,
+ })
+ .returning("id")
+ .executeTakeFirstOrThrow();
+}
+
+const withApprovals = (eb: ExpressionBuilder) => {
+ return jsonArrayFrom(
+ eb
+ .selectFrom("PendingTrophyApproval")
+ .innerJoin("User", "PendingTrophyApproval.userId", "User.id")
+ .select([
+ "PendingTrophyApproval.userId",
+ "PendingTrophyApproval.createdAt",
+ "User.username",
+ ])
+ .whereRef(
+ "PendingTrophyApproval.pendingTrophyId",
+ "=",
+ "PendingTrophy.id",
+ )
+ .orderBy("PendingTrophyApproval.createdAt", "asc"),
+ ).as("approvals");
+};
+
+const withTarget = (eb: ExpressionBuilder) => {
+ return jsonObjectFrom(
+ eb
+ .selectFrom("Trophy")
+ .leftJoin("User", "User.id", "Trophy.managerId")
+ .leftJoin(
+ "TournamentOrganization",
+ "TournamentOrganization.id",
+ "Trophy.organizationId",
+ )
+ .select([
+ "Trophy.id",
+ "Trophy.name",
+ "Trophy.model",
+ "Trophy.organizationId",
+ "Trophy.managerId",
+ "User.username as managerUsername",
+ "TournamentOrganization.name as organizationName",
+ "TournamentOrganization.slug as organizationSlug",
+ ])
+ .whereRef("Trophy.id", "=", "PendingTrophy.targetTrophyId"),
+ ).as("target");
+};
+
+const withTargetManager = (eb: ExpressionBuilder) => {
+ return jsonObjectFrom(
+ eb
+ .selectFrom("User")
+ .select(["User.id", "User.username", "User.discordId"])
+ .whereRef("User.id", "=", "PendingTrophy.managerId"),
+ ).as("manager");
+};
+
+function pendingBaseQuery() {
+ return db
+ .selectFrom("PendingTrophy")
+ .leftJoin(
+ "User as Submitter",
+ "Submitter.id",
+ "PendingTrophy.submitterUserId",
+ )
+ .leftJoin(
+ "User as Decliner",
+ "Decliner.id",
+ "PendingTrophy.declinedByUserId",
+ )
+ .leftJoin(
+ "TournamentOrganization",
+ "TournamentOrganization.id",
+ "PendingTrophy.organizationId",
+ )
+ .select((eb) => [
+ "PendingTrophy.id",
+ "PendingTrophy.name",
+ "PendingTrophy.model",
+ "PendingTrophy.description",
+ "PendingTrophy.organizationId",
+ "PendingTrophy.submitterUserId",
+ "PendingTrophy.createdAt",
+ "PendingTrophy.declineReason",
+ "PendingTrophy.declinedAt",
+ "PendingTrophy.declinedByUserId",
+ "PendingTrophy.targetTrophyId",
+ "PendingTrophy.managerId",
+ "Submitter.username as submitterUsername",
+ "Submitter.discordId as submitterDiscordId",
+ "Decliner.username as declinedByUsername",
+ "TournamentOrganization.name as organizationName",
+ "TournamentOrganization.slug as organizationSlug",
+ withApprovals(eb),
+ withTarget(eb),
+ withTargetManager(eb),
+ ]);
+}
+
+export async function findPendingById(id: number) {
+ const row = await pendingBaseQuery()
+ .where("PendingTrophy.id", "=", id)
+ .executeTakeFirst();
+
+ return row ?? null;
+}
+
+export async function allPending() {
+ return pendingBaseQuery()
+ .orderBy("PendingTrophy.createdAt", "desc")
+ .execute();
+}
+
+export async function pendingBySubmitter(submitterUserId: number) {
+ return pendingBaseQuery()
+ .where("PendingTrophy.submitterUserId", "=", submitterUserId)
+ .orderBy("PendingTrophy.createdAt", "desc")
+ .execute();
+}
+
+export async function unreviewedCountBySubmitter(submitterUserId: number) {
+ const row = await db
+ .selectFrom("PendingTrophy")
+ .select((eb) => eb.fn.countAll().as("count"))
+ .where("submitterUserId", "=", submitterUserId)
+ .where("declinedAt", "is", null)
+ .where((eb) =>
+ eb(
+ eb
+ .selectFrom("PendingTrophyApproval")
+ .select((eb2) => eb2.fn.countAll().as("approvalCount"))
+ .whereRef(
+ "PendingTrophyApproval.pendingTrophyId",
+ "=",
+ "PendingTrophy.id",
+ ),
+ "<",
+ TROPHY_APPROVALS_REQUIRED,
+ ),
+ )
+ .executeTakeFirstOrThrow();
+
+ return row.count;
+}
+
+export async function deletePending(id: number) {
+ await db.deleteFrom("PendingTrophy").where("id", "=", id).execute();
+}
+
+export async function declinePending(args: {
+ id: number;
+ reason: string;
+ declinedByUserId: number;
+}) {
+ return db.transaction().execute(async (trx) => {
+ const { count } = await trx
+ .selectFrom("PendingTrophyApproval")
+ .select((eb) => eb.fn.countAll().as("count"))
+ .where("pendingTrophyId", "=", args.id)
+ .executeTakeFirstOrThrow();
+
+ if (count >= TROPHY_APPROVALS_REQUIRED) {
+ return false;
+ }
+
+ await trx
+ .deleteFrom("PendingTrophyApproval")
+ .where("pendingTrophyId", "=", args.id)
+ .execute();
+
+ await trx
+ .updateTable("PendingTrophy")
+ .set({
+ declineReason: args.reason,
+ declinedAt: dateToDatabaseTimestamp(new Date()),
+ declinedByUserId: args.declinedByUserId,
+ })
+ .where("id", "=", args.id)
+ .execute();
+
+ return true;
+ });
+}
+
+export async function addApproval(args: {
+ pendingTrophyId: number;
+ userId: number;
+}) {
+ return db.transaction().execute(async (trx) => {
+ const insertResult = await trx
+ .insertInto("PendingTrophyApproval")
+ .values({
+ pendingTrophyId: args.pendingTrophyId,
+ userId: args.userId,
+ createdAt: dateToDatabaseTimestamp(new Date()),
+ })
+ .onConflict((oc) => oc.doNothing())
+ .executeTakeFirst();
+
+ if (!insertResult.numInsertedOrUpdatedRows) {
+ return null;
+ }
+
+ const { count } = await trx
+ .selectFrom("PendingTrophyApproval")
+ .select((eb) => eb.fn.countAll().as("count"))
+ .where("pendingTrophyId", "=", args.pendingTrophyId)
+ .executeTakeFirstOrThrow();
+
+ if (count !== TROPHY_APPROVALS_REQUIRED) {
+ return null;
+ }
+
+ const pending = await trx
+ .selectFrom("PendingTrophy")
+ .select([
+ "id",
+ "name",
+ "model",
+ "organizationId",
+ "submitterUserId",
+ "targetTrophyId",
+ "managerId",
+ ])
+ .where("id", "=", args.pendingTrophyId)
+ .where("declinedAt", "is", null)
+ .executeTakeFirst();
+
+ if (!pending) return null;
+
+ if (pending.targetTrophyId !== null) {
+ await trx
+ .updateTable("Trophy")
+ .set({
+ name: pending.name,
+ model: pending.model,
+ organizationId: pending.organizationId,
+ managerId: pending.managerId ?? pending.submitterUserId,
+ })
+ .where("id", "=", pending.targetTrophyId)
+ .execute();
+ return { id: pending.targetTrophyId };
+ }
+
+ return trx
+ .insertInto("Trophy")
+ .values({
+ name: pending.name,
+ model: pending.model,
+ organizationId: pending.organizationId,
+ creatorId: pending.submitterUserId,
+ managerId: pending.managerId ?? pending.submitterUserId,
+ })
+ .returning("id")
+ .executeTakeFirstOrThrow();
+ });
+}
diff --git a/app/features/trophies/actions/trophies.new.server.ts b/app/features/trophies/actions/trophies.new.server.ts
new file mode 100644
index 000000000..521f82788
--- /dev/null
+++ b/app/features/trophies/actions/trophies.new.server.ts
@@ -0,0 +1,231 @@
+import type { ActionFunction } from "react-router";
+import { ADMIN_ID, QA_IDS } from "~/features/admin/admin-constants";
+import {
+ type AuthenticatedUser,
+ requireUser,
+} from "~/features/auth/core/user.server";
+import { notify } from "~/features/notifications/core/notify.server";
+import { clearTrophiesCache } from "~/features/trophies/loaders/trophies.server";
+import * as UserRepository from "~/features/user-page/UserRepository.server";
+import { parseFormData } from "~/form/parse.server";
+import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
+import { assertUnreachable } from "~/utils/types";
+import * as TrophyRepository from "../TrophyRepository.server";
+import {
+ TROPHY_APPROVALS_REQUIRED,
+ TROPHY_PENDING_PER_USER_LIMIT,
+} from "../trophies-constants";
+import {
+ pendingTrophyActionSchema,
+ trophyFormSchema,
+} from "../trophies-schemas";
+import {
+ canAccessTrophies,
+ canEditTrophy,
+ canReviewTrophies,
+ compressTrophyModel,
+} from "../trophies-utils";
+
+export const action: ActionFunction = async ({ request }) => {
+ const user = requireUser();
+ if (!canAccessTrophies(user)) {
+ throw new Response(null, { status: 404 });
+ }
+
+ const isJson = request.headers.get("Content-Type") === "application/json";
+
+ if (isJson) {
+ const result = await parseFormData({
+ request,
+ schema: trophyFormSchema,
+ });
+
+ if (!result.success) {
+ return { fieldErrors: result.fieldErrors };
+ }
+
+ const data = result.data;
+
+ const pendingCount = await TrophyRepository.unreviewedCountBySubmitter(
+ user.id,
+ );
+ errorToastIfFalsy(
+ pendingCount < TROPHY_PENDING_PER_USER_LIMIT,
+ "Pending trophy limit reached",
+ );
+
+ if (data._action === "UPDATE") {
+ const trophy = await TrophyRepository.findById(data.targetTrophyId);
+ errorToastIfFalsy(trophy, "Trophy not found");
+ errorToastIfFalsy(
+ canEditTrophy(user, { managerId: trophy.manager?.id ?? null }),
+ "Not allowed",
+ );
+
+ const nameExists = await TrophyRepository.existsByName({
+ name: data.name,
+ excludeTrophyId: data.targetTrophyId,
+ });
+ if (nameExists) {
+ return { fieldErrors: { name: "forms:errors.trophyNameTaken" } };
+ }
+
+ await TrophyRepository.createPending({
+ name: data.name,
+ model: compressTrophyModel(data.model),
+ description: data.description ?? "",
+ organizationId: data.organizationId,
+ submitterUserId: user.id,
+ targetTrophyId: data.targetTrophyId,
+ managerId: data.managerId,
+ });
+
+ await notifyReviewersOfSubmission({
+ trophyName: data.name,
+ submitter: user,
+ });
+
+ return null;
+ }
+
+ const nameExists = await TrophyRepository.existsByName({
+ name: data.name,
+ });
+ if (nameExists) {
+ return { fieldErrors: { name: "forms:errors.trophyNameTaken" } };
+ }
+
+ await TrophyRepository.createPending({
+ name: data.name,
+ model: compressTrophyModel(data.model),
+ description: data.description ?? "",
+ organizationId: data.organizationId,
+ submitterUserId: user.id,
+ });
+
+ await notifyReviewersOfSubmission({
+ trophyName: data.name,
+ submitter: user,
+ });
+
+ return null;
+ }
+
+ const data = await parseRequestPayload({
+ request,
+ schema: pendingTrophyActionSchema,
+ });
+
+ switch (data._action) {
+ case "DELETE": {
+ const pending = await TrophyRepository.findPendingById(
+ data.pendingTrophyId,
+ );
+ errorToastIfFalsy(pending, "Pending trophy not found");
+
+ const isOwner = pending.submitterUserId === user.id;
+ const canReview = canReviewTrophies(user);
+ errorToastIfFalsy(isOwner || canReview, "Not allowed");
+
+ await TrophyRepository.deletePending(data.pendingTrophyId);
+ return null;
+ }
+ case "DECLINE": {
+ errorToastIfFalsy(canReviewTrophies(user), "Not allowed");
+
+ const pending = await TrophyRepository.findPendingById(
+ data.pendingTrophyId,
+ );
+ errorToastIfFalsy(pending, "Pending trophy not found");
+ errorToastIfFalsy(!pending.declinedAt, "Trophy is already declined");
+ errorToastIfFalsy(
+ pending.approvals.length < TROPHY_APPROVALS_REQUIRED,
+ "Cannot decline an accepted trophy",
+ );
+
+ const declined = await TrophyRepository.declinePending({
+ id: data.pendingTrophyId,
+ reason: data.reason,
+ declinedByUserId: user.id,
+ });
+ errorToastIfFalsy(declined, "Cannot decline an accepted trophy");
+
+ if (pending.submitterUserId !== user.id) {
+ notify({
+ userIds: [pending.submitterUserId],
+ notification: {
+ type: "TROPHY_SUBMISSION_DECLINED",
+ meta: { trophyName: pending.name },
+ },
+ });
+ }
+
+ return null;
+ }
+ case "APPROVE": {
+ errorToastIfFalsy(canReviewTrophies(user), "Not allowed");
+
+ const pending = await TrophyRepository.findPendingById(
+ data.pendingTrophyId,
+ );
+
+ errorToastIfFalsy(pending, "Pending trophy not found");
+ errorToastIfFalsy(
+ !pending.declinedAt,
+ "Cannot approve a declined trophy",
+ );
+ errorToastIfFalsy(
+ pending.approvals.length < TROPHY_APPROVALS_REQUIRED,
+ "Trophy is already accepted",
+ );
+ errorToastIfFalsy(
+ !pending.approvals.some((a) => a.userId === user.id),
+ "Already approved",
+ );
+
+ const inserted = await TrophyRepository.addApproval({
+ pendingTrophyId: data.pendingTrophyId,
+ userId: user.id,
+ });
+
+ if (inserted) {
+ clearTrophiesCache();
+
+ if (pending.submitterUserId !== user.id) {
+ notify({
+ userIds: [pending.submitterUserId],
+ notification: {
+ type: "TROPHY_SUBMISSION_ACCEPTED",
+ meta: { trophyName: pending.name, trophyId: inserted.id },
+ },
+ });
+ }
+ }
+
+ return null;
+ }
+ default: {
+ assertUnreachable(data);
+ }
+ }
+};
+
+async function notifyReviewersOfSubmission({
+ trophyName,
+ submitter,
+}: {
+ trophyName: string;
+ submitter: AuthenticatedUser;
+}) {
+ const reviewerIds = await UserRepository.existingUserIds(
+ [ADMIN_ID, ...QA_IDS].filter((id) => id !== submitter.id),
+ );
+
+ notify({
+ userIds: reviewerIds,
+ notification: {
+ type: "TROPHY_SUBMITTED",
+ meta: { trophyName, submitterUsername: submitter.username },
+ },
+ });
+}
diff --git a/app/features/trophies/components/TournamentSummaryRow.module.css b/app/features/trophies/components/TournamentSummaryRow.module.css
new file mode 100644
index 000000000..3f5708a44
--- /dev/null
+++ b/app/features/trophies/components/TournamentSummaryRow.module.css
@@ -0,0 +1,57 @@
+.row {
+ display: flex;
+ align-items: center;
+ gap: var(--s-2);
+ padding: var(--s-1-5);
+ border-radius: var(--radius-field);
+
+ &:hover {
+ background-color: var(--color-bg-high);
+ }
+}
+
+.logo {
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.name {
+ font-size: var(--font-sm);
+ font-weight: var(--weight-semi);
+ display: flex;
+ align-items: center;
+ gap: 0 var(--s-2);
+ flex-wrap: wrap;
+}
+
+.upcomingPill {
+ display: inline-flex;
+ align-items: center;
+ height: var(--selector-size-xs);
+ padding: 0 var(--s-1-5);
+ border-radius: var(--radius-selector);
+ background-color: var(--color-text-accent);
+ color: var(--color-text-inverse);
+ font-size: var(--font-2xs);
+ font-weight: var(--weight-bold);
+}
+
+.meta {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--s-2);
+ color: var(--color-text-high);
+ font-size: var(--font-2xs);
+}
+
+.metaItem {
+ display: flex;
+ align-items: center;
+ gap: var(--s-1);
+}
+
+.metaIcon {
+ width: 12px;
+ height: 12px;
+}
diff --git a/app/features/trophies/components/TournamentSummaryRow.tsx b/app/features/trophies/components/TournamentSummaryRow.tsx
new file mode 100644
index 000000000..250274d71
--- /dev/null
+++ b/app/features/trophies/components/TournamentSummaryRow.tsx
@@ -0,0 +1,99 @@
+import { clsx } from "clsx";
+import { Users } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import { Link } from "react-router";
+import { TierPill } from "~/components/TierPill";
+import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
+import { useFormatDistanceToNow } from "~/hooks/intl/useFormatDistanceToNow";
+import { useHydrated } from "~/hooks/useHydrated";
+import { databaseTimestampToDate } from "~/utils/dates";
+import { tournamentPage } from "~/utils/urls";
+import styles from "./TournamentSummaryRow.module.css";
+
+export function TournamentSummaryRow({
+ tournament,
+ className,
+}: {
+ tournament: {
+ tournamentId: number;
+ name: string;
+ logoUrl: string;
+ teamsCount: number | null;
+ tier?: number | null;
+ tentativeTier?: number | null;
+ startTime?: number | null;
+ };
+ className?: string;
+}) {
+ const { t } = useTranslation(["trophies"]);
+ const isHydrated = useHydrated();
+ const formatDistanceToNow = useFormatDistanceToNow();
+ const { formatter } = useDateTimeFormat({
+ day: "numeric",
+ month: "short",
+ year: "numeric",
+ });
+
+ const isUpcoming = Boolean(
+ tournament.startTime &&
+ databaseTimestampToDate(tournament.startTime) > new Date(),
+ );
+
+ return (
+
+
+
+
+ {tournament.name}
+ {tournament.tier ? (
+
+ ) : tournament.tentativeTier ? (
+
+ ) : null}
+ {isUpcoming ? (
+
+ {t("trophies:details.upcoming")}
+
+ ) : null}
+
+
+
+
+ {tournament.teamsCount}
+
+ {tournament.startTime ? (
+ isUpcoming ? (
+
+ {isHydrated
+ ? formatDistanceToNow(tournament.startTime, {
+ addSuffix: true,
+ })
+ : "Placeholder"}
+
+ ) : (
+
+ {formatter.format(tournament.startTime)}
+
+ )
+ ) : null}
+
+
+
+ );
+}
diff --git a/app/features/trophies/components/TrophiesSelector.module.css b/app/features/trophies/components/TrophiesSelector.module.css
new file mode 100644
index 000000000..0031adcfb
--- /dev/null
+++ b/app/features/trophies/components/TrophiesSelector.module.css
@@ -0,0 +1,54 @@
+.list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-2);
+ padding: 0;
+ margin: 0;
+ list-style: none;
+}
+
+.item {
+ display: flex;
+ align-items: center;
+ gap: var(--s-3);
+ padding: var(--s-1) var(--s-2);
+ border: var(--border-style);
+ border-radius: var(--radius-box);
+ background-color: var(--color-bg-high);
+ transition: opacity 0.2s;
+
+ &.isDragging {
+ opacity: 0.5;
+ }
+}
+
+.dragHandle {
+ cursor: grab;
+ user-select: none;
+ color: var(--color-text-high);
+ font-size: var(--font-md);
+ touch-action: none;
+ background: none;
+ border: none;
+ padding: 0;
+ flex-shrink: 0;
+
+ &:active {
+ cursor: grabbing;
+ }
+}
+
+.preview {
+ width: 40px;
+ flex-shrink: 0;
+}
+
+.name {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: var(--font-sm);
+ font-weight: var(--weight-semi);
+}
diff --git a/app/features/trophies/components/TrophiesSelector.tsx b/app/features/trophies/components/TrophiesSelector.tsx
new file mode 100644
index 000000000..e12354f1d
--- /dev/null
+++ b/app/features/trophies/components/TrophiesSelector.tsx
@@ -0,0 +1,177 @@
+import type { DragEndEvent } from "@dnd-kit/core";
+import {
+ closestCenter,
+ DndContext,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+} from "@dnd-kit/core";
+import {
+ SortableContext,
+ sortableKeyboardCoordinates,
+ useSortable,
+ verticalListSortingStrategy,
+} from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+import clsx from "clsx";
+import { Trash } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import { SendouButton } from "~/components/elements/Button";
+import styles from "./TrophiesSelector.module.css";
+import { Trophy, TrophyContextProvider } from "./Trophy";
+import type { TrophyDisplayProps } from "./TrophyDisplay";
+
+type TrophyItem = TrophyDisplayProps["trophies"][number];
+
+export function TrophiesSelector({
+ options,
+ selectedTrophies,
+ onChange,
+ onBlur,
+ maxCount,
+}: {
+ options: TrophyDisplayProps["trophies"];
+ selectedTrophies: number[];
+ onChange: (newTrophies: number[]) => void;
+ onBlur?: () => void;
+ maxCount?: number;
+}) {
+ const { t } = useTranslation(["common"]);
+
+ const selectedTrophiesInOrder = selectedTrophies
+ .map((id) => options.find((trophy) => trophy.id === id))
+ .filter((trophy): trophy is TrophyItem => trophy !== undefined);
+
+ const sensors = useSensors(
+ useSensor(PointerSensor),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ }),
+ );
+
+ const handleRemove = (trophyId: number) => {
+ onChange(selectedTrophies.filter((id) => id !== trophyId));
+ };
+
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event;
+ if (!over || active.id === over.id) return;
+
+ const oldIndex = selectedTrophies.indexOf(active.id as number);
+ const newIndex = selectedTrophies.indexOf(over.id as number);
+ if (oldIndex === -1 || newIndex === -1) return;
+
+ const newOrder = [...selectedTrophies];
+ const [removed] = newOrder.splice(oldIndex, 1);
+ newOrder.splice(newIndex, 0, removed);
+ onChange(newOrder);
+ };
+
+ return (
+
+ {selectedTrophiesInOrder.length > 0 ? (
+
+
+ trophy.id)}
+ strategy={verticalListSortingStrategy}
+ >
+
+ {selectedTrophiesInOrder.map((trophy) => (
+
+ ))}
+
+
+
+
+ ) : (
+
+ {t("common:trophies.selector.none")}
+
+ )}
+ {options.length === 0 ? (
+
+ {t("common:trophies.selector.noneAvailable")}
+
+ ) : (
+
onBlur?.()}
+ onChange={(e) =>
+ onChange([...selectedTrophies, Number(e.target.value)])
+ }
+ disabled={Boolean(maxCount && selectedTrophies.length >= maxCount)}
+ data-testid="trophies-selector"
+ >
+ {t("common:trophies.selector.select")}
+ {options
+ .filter((trophy) => !selectedTrophies.includes(trophy.id))
+ .map((trophy) => (
+
+ {trophy.name}
+
+ ))}
+
+ )}
+
+ );
+}
+
+function SortableTrophyItem({
+ trophy,
+ onRemove,
+}: {
+ trophy: TrophyItem;
+ onRemove: (id: number) => void;
+}) {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id: trophy.id });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ };
+
+ return (
+
+
+ ☰
+
+
+
+
+ {trophy.name}
+ }
+ aria-label="Remove"
+ onPress={() => onRemove(trophy.id)}
+ />
+
+ );
+}
diff --git a/app/features/trophies/components/Trophy.module.css b/app/features/trophies/components/Trophy.module.css
new file mode 100644
index 000000000..0dfd2a289
--- /dev/null
+++ b/app/features/trophies/components/Trophy.module.css
@@ -0,0 +1,73 @@
+.container {
+ position: relative;
+}
+
+.tile {
+ border: var(--border-width) solid var(--tier-bg);
+ border-radius: var(--radius-field);
+ background-color: var(--color-bg-high);
+}
+
+.grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, 100px);
+ justify-content: center;
+ gap: var(--s-2);
+}
+
+.placeholder {
+ width: 100%;
+ aspect-ratio: 1 / 1;
+}
+
+.tierPill {
+ position: absolute;
+ top: -1px;
+ right: -1px;
+
+ > * {
+ border-radius: 0 var(--radius-field) 0 var(--radius-field);
+ }
+}
+
+.cornerPill {
+ position: absolute;
+ bottom: -1px;
+ left: -1px;
+ display: flex;
+ align-items: center;
+ height: var(--selector-size-sm);
+ padding: 0 var(--s-1-5);
+ border-radius: 0 var(--radius-field) 0 var(--radius-field);
+ background-color: var(--tier-bg, var(--color-bg-higher));
+ color: var(--tier-text, var(--color-text-high));
+ font-size: var(--font-2xs);
+ font-weight: var(--weight-bold);
+}
+
+.trophy {
+ width: 100% !important;
+ height: auto !important;
+ aspect-ratio: 1 / 1;
+ opacity: 1;
+ transition: opacity 0.2s ease;
+
+ @starting-style {
+ opacity: 0;
+ }
+}
+
+.interactive {
+ cursor: grab;
+
+ &:active {
+ cursor: grabbing;
+ }
+}
+
+.error {
+ color: var(--color-text-high);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
diff --git a/app/features/trophies/components/Trophy.tsx b/app/features/trophies/components/Trophy.tsx
new file mode 100644
index 000000000..e8116479b
--- /dev/null
+++ b/app/features/trophies/components/Trophy.tsx
@@ -0,0 +1,255 @@
+import { clsx } from "clsx";
+import { Ban } from "lucide-react";
+import { PicoCAD2Context, PicoCAD2Viewer } from "picocad2-web";
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
+import { TierPill } from "~/components/TierPill";
+import { IS_E2E_TEST_RUN } from "~/utils/e2e";
+import { decompressTrophyModel } from "../trophies-utils";
+import style from "./Trophy.module.css";
+
+type TrophyCtxValue =
+ | { context: PicoCAD2Context }
+ | { context: undefined; isLoading: true };
+
+const TrophyCtx = createContext(undefined);
+
+/**
+ * Shares one PicoCAD2 WebGL context across every `Trophy` rendered inside.
+ *
+ * `Trophy` falls back to creating its own internal context when no provider is
+ * used, but browsers cap active WebGL contexts at 16 so any grid bigger than
+ * that, or rapid mounting/unmounting, triggers a warning and broken rendering.
+ *
+ * We use two implementations to stay under the limit:
+ *
+ * 1. One page wide `PicoCAD2Context` singleton.
+ * Creating a new one per mount can stack contexts faster than the browser
+ * can remove them. Holding one for the page lifetime is cheaper in comparison.
+ *
+ * 2. The provider always renders its children so surrounding layout doesn't shift
+ * but uses an additional `isLoading` bool while `useState` is still `undefined`
+ * before the first effect fires. Descendant `Trophy` components read the bool
+ * and render an empty spacer instead of mounting a canvas that would create
+ * their own internal context.
+ */
+
+let sharedContext: PicoCAD2Context | undefined;
+
+function getSharedTrophyContext() {
+ if (typeof window === "undefined") return undefined;
+ if (!sharedContext) sharedContext = new PicoCAD2Context();
+ return sharedContext;
+}
+
+export function TrophyContextProvider({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const [context, setContext] = useState();
+
+ useEffect(() => {
+ setContext(getSharedTrophyContext());
+ }, []);
+
+ const value: TrophyCtxValue = context
+ ? { context }
+ : { context: undefined, isLoading: true };
+
+ return {children} ;
+}
+
+export function TrophyGrid({ children }: { children: React.ReactNode }) {
+ return {children}
;
+}
+
+export function TrophyPlaceholder() {
+ return
;
+}
+
+export function Trophy({
+ model,
+ className,
+ preview,
+ tile,
+ tier,
+ tentativeTier,
+ disableCameraControls,
+ staticOnSoftwareRendering,
+ pill,
+}: {
+ model: string;
+ className?: string;
+ preview?: boolean;
+ tile?: boolean;
+ tier?: number | null;
+ tentativeTier?: number | null;
+ disableCameraControls?: boolean;
+ staticOnSoftwareRendering?: boolean;
+ pill?: React.ReactNode;
+}) {
+ const ctxValue = useContext(TrophyCtx);
+ const context = ctxValue?.context;
+ const isLoadingSharedContext =
+ ctxValue !== undefined && ctxValue.context === undefined;
+ const viewerRef = useRef(null);
+ const [error, setError] = useState(false);
+
+ const prevModelRef = useRef(model);
+ if (prevModelRef.current !== model) {
+ prevModelRef.current = model;
+ setError(false);
+ }
+
+ const modelState = decompressTrophyModel(model);
+
+ // useCallback is needed to keep the ref callback identity stable.
+ // A new identity each render would make React detach and re-attach,
+ // disposing and rebuilding the viewer on every re-render
+ const canvasRef = useCallback(
+ (canvas: HTMLCanvasElement | null) => {
+ if (!canvas) {
+ viewerRef.current?.dispose();
+ viewerRef.current = null;
+ return;
+ }
+
+ if (modelState === null) return;
+
+ const viewer = new PicoCAD2Viewer({
+ canvas,
+ context,
+ resolution: { width: 128, height: 128, scale: 4 },
+ });
+ viewerRef.current = viewer;
+
+ try {
+ viewer.setState(JSON.parse(modelState));
+ } catch (_) {
+ setError(true);
+ return;
+ }
+
+ // continuous render loops starve the main thread when WebGL is
+ // software rendered, so e2e runs (which always render on CPU) draw a
+ // single static frame, as do surfaces showing many loops at once on
+ // devices without GPU acceleration
+ if (
+ preview ||
+ IS_E2E_TEST_RUN ||
+ (staticOnSoftwareRendering && isSoftwareRendering())
+ ) {
+ viewer.draw();
+ viewer.dispose();
+ return;
+ }
+
+ viewer.cameraMode = "spin";
+ viewer.cameraModeSpeed = 5;
+ viewer.startRenderLoop(false);
+
+ if (disableCameraControls) return;
+
+ viewer.enableCameraControls({
+ spinInertiaFactor: 0.95,
+ pan: false,
+ rotate: true,
+ zoom: true,
+ useFixedOnInteract: {
+ enabled: true,
+ delayBeforeRestore: 1000,
+ restoreTime: 1000,
+ },
+ });
+ },
+ [
+ modelState,
+ context,
+ preview,
+ staticOnSoftwareRendering,
+ disableCameraControls,
+ ],
+ );
+
+ const effectiveTier = tier ?? tentativeTier ?? null;
+ const containerClassName = clsx(style.container, className, {
+ [style.tile]: tile,
+ });
+ const containerStyle = effectiveTier
+ ? ({
+ "--tier-bg": `var(--tier-bg-${effectiveTier})`,
+ "--tier-text": `var(--tier-text-${effectiveTier})`,
+ } as React.CSSProperties)
+ : undefined;
+
+ const tierPill = tier ? (
+
+
+
+ ) : tentativeTier ? (
+
+
+
+ ) : null;
+
+ const cornerPill = pill ? (
+
+ {pill}
+
+ ) : null;
+
+ if (error || modelState === null) {
+ return (
+
+
+
+
+ {tierPill}
+ {cornerPill}
+
+ );
+ }
+
+ if (isLoadingSharedContext) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {tierPill}
+ {cornerPill}
+
+ );
+}
+
+let softwareRenderingDetected: boolean | undefined;
+
+function isSoftwareRendering() {
+ if (softwareRenderingDetected !== undefined) return softwareRenderingDetected;
+ if (typeof document === "undefined") return false;
+
+ const gl = document
+ .createElement("canvas")
+ .getContext("webgl2", { failIfMajorPerformanceCaveat: true });
+ softwareRenderingDetected = !gl;
+ gl?.getExtension("WEBGL_lose_context")?.loseContext();
+
+ return softwareRenderingDetected;
+}
diff --git a/app/features/trophies/components/TrophyDisplay.module.css b/app/features/trophies/components/TrophyDisplay.module.css
new file mode 100644
index 000000000..b8109d8bb
--- /dev/null
+++ b/app/features/trophies/components/TrophyDisplay.module.css
@@ -0,0 +1,112 @@
+.root {
+ display: flex;
+ flex-direction: column;
+ margin-inline: auto;
+ width: max-content;
+}
+
+.grid {
+ display: grid;
+ grid-template-columns: repeat(3, 100px);
+ justify-content: center;
+ gap: var(--s-2);
+ padding: var(--s-2);
+ border: var(--border-style);
+ border-radius: var(--radius-box);
+}
+
+.trophyButton {
+ all: unset;
+ cursor: pointer;
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.win {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-1-5);
+ opacity: 1;
+ transition: opacity 0.2s ease;
+
+ @starting-style {
+ opacity: 0;
+ }
+}
+
+.winHeader {
+ margin-top: var(--s-2);
+}
+
+.winDetails {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-2);
+ padding-inline: var(--s-2);
+}
+
+.winMembers {
+ display: flex;
+ flex-direction: column;
+}
+
+.winMember {
+ display: grid;
+ grid-template-columns: 100px 1fr max-content;
+ align-items: center;
+ gap: var(--s-2);
+ font-size: var(--font-xs);
+ border-radius: var(--radius-field);
+ padding: var(--s-1);
+
+ & > * {
+ min-width: 0;
+ max-width: 100%;
+ }
+
+ &:nth-child(odd) {
+ background-color: var(--color-bg-high);
+ }
+}
+
+@container (width >= 560px) and (width <= 750px) {
+ .winMember {
+ grid-template-columns: 1fr 1fr;
+ grid-template-rows: auto auto;
+ justify-items: center;
+
+ & > button {
+ grid-column: 1 / -1;
+ }
+ }
+}
+
+.winMemberUser {
+ display: flex;
+ align-items: center;
+ gap: var(--s-1-5);
+ font-weight: var(--weight-semi);
+ min-width: 0;
+}
+
+.winMemberName {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.winMemberWeapons {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: flex-end;
+ gap: var(--s-1);
+}
+
+.specialDescription {
+ font-size: var(--font-sm);
+ color: var(--color-text-high);
+ padding-top: var(--s-2);
+}
diff --git a/app/features/trophies/components/TrophyDisplay.tsx b/app/features/trophies/components/TrophyDisplay.tsx
new file mode 100644
index 000000000..4efcf3b5a
--- /dev/null
+++ b/app/features/trophies/components/TrophyDisplay.tsx
@@ -0,0 +1,211 @@
+import clsx from "clsx";
+import * as React from "react";
+import { useTranslation } from "react-i18next";
+import { useFetcher } from "react-router";
+import { Avatar } from "~/components/Avatar";
+import { Divider } from "~/components/Divider";
+import { DotPagination } from "~/components/DotPagination";
+import { WeaponImage } from "~/components/Image";
+import { UserCard } from "~/features/user-card/components/UserCard";
+import { ParticipationPill } from "~/features/user-page/components/ParticipationPill";
+import { usePagination } from "~/hooks/usePagination";
+import { trophyWinsPage } from "~/utils/urls";
+import type { TrophyWinsLoaderData } from "../routes/trophies.$id.wins.$userId";
+import { SMALL_TROPHIES_PER_DISPLAY_PAGE } from "../trophies-constants";
+import {
+ parseSpecialTrophyCode,
+ useProgressiveRender,
+} from "../trophies-utils";
+import { TournamentSummaryRow } from "./TournamentSummaryRow";
+import { Trophy, TrophyContextProvider, TrophyPlaceholder } from "./Trophy";
+import styles from "./TrophyDisplay.module.css";
+import { TrophyShowcaseModal } from "./TrophyShowcase";
+
+type TrophyItem = {
+ id: number;
+ name: string;
+ model: string;
+ tier?: number | null;
+ code?: string | null;
+ count?: number | null;
+};
+
+export interface TrophyDisplayProps {
+ trophies: Array;
+ userId: number;
+ className?: string;
+}
+
+export function TrophyDisplay({
+ trophies,
+ userId,
+ className,
+}: TrophyDisplayProps) {
+ const [openTrophy, setOpenTrophy] = React.useState(null);
+
+ const {
+ itemsToDisplay,
+ everythingVisible,
+ currentPage,
+ pagesCount,
+ setPage,
+ } = usePagination({
+ items: trophies,
+ pageSize: SMALL_TROPHIES_PER_DISPLAY_PAGE,
+ scrollToTop: false,
+ });
+
+ const visibleCount = useProgressiveRender(
+ itemsToDisplay.length,
+ String(currentPage),
+ );
+
+ if (trophies.length === 0) return null;
+
+ return (
+
+
+
+ {itemsToDisplay.map((trophy, i) =>
+ i < visibleCount ? (
+ setOpenTrophy(trophy)}
+ aria-label={trophy.name}
+ >
+ 1
+ ? `×${trophy.count}`
+ : undefined
+ }
+ />
+
+ ) : (
+
+ ),
+ )}
+
+ {!everythingVisible ? (
+
+ ) : null}
+
+ {openTrophy ? (
+ setOpenTrophy(null)}
+ />
+ ) : null}
+
+ );
+}
+
+function TrophyModal({
+ trophy,
+ userId,
+ onClose,
+}: {
+ trophy: TrophyItem;
+ userId: number;
+ onClose: () => void;
+}) {
+ const { t } = useTranslation(["trophies"]);
+ const fetcher = useFetcher();
+ const data = fetcher.data;
+
+ const special = parseSpecialTrophyCode(trophy.code);
+
+ const loadedRef = React.useRef(false);
+ React.useEffect(() => {
+ if (parseSpecialTrophyCode(trophy.code) || loadedRef.current) return;
+ loadedRef.current = true;
+ fetcher.load(trophyWinsPage({ trophyId: trophy.id, userId }));
+ }, [fetcher.load, trophy.id, trophy.code, userId]);
+
+ return (
+
+ {special ? (
+
+
+
+ {special.type === "supporter"
+ ? t("trophies:special.supporter.description")
+ : t("trophies:special.xp.description", {
+ value: special.value,
+ })}
+
+
+ ) : null}
+ {data
+ ? data.wins.map((win) => (
+
+ ))
+ : null}
+
+ );
+}
+
+function TrophyWinDetails({
+ win,
+ userCards,
+}: {
+ win: TrophyWinsLoaderData["wins"][number];
+ userCards: TrophyWinsLoaderData["userCards"];
+}) {
+ return (
+
+
+
+ {win.members.length > 0 ? (
+
+ {win.members.map((member) => (
+
+
+
+
+
+ {member.username}
+
+
+
+
+ {member.weapons.map((weaponSplId) => (
+
+ ))}
+
+
+
+ ))}
+
+ ) : null}
+
+
+ );
+}
diff --git a/app/features/trophies/components/TrophyShowcase.module.css b/app/features/trophies/components/TrophyShowcase.module.css
new file mode 100644
index 000000000..271384493
--- /dev/null
+++ b/app/features/trophies/components/TrophyShowcase.module.css
@@ -0,0 +1,47 @@
+.wrapper {
+ container-type: inline-size;
+}
+
+.modal {
+ padding: 0;
+ max-width: 48rem;
+}
+
+.trophyName {
+ font-weight: var(--weight-bold);
+ font-size: var(--font-lg);
+}
+
+.trophyPageLink {
+ font-size: var(--font-2xs);
+ margin-bottom: var(--s-2);
+}
+
+.content {
+ display: grid;
+ grid-template-columns: 1fr;
+ align-items: center;
+ background-color: var(--color-bg-high);
+}
+
+.details {
+ padding: var(--s-4);
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-2);
+ background-color: var(--color-bg);
+ border-top: var(--border-style);
+ overflow-y: auto;
+}
+
+@container (width >= 560px) {
+ .content {
+ grid-template-columns: 60% 1fr;
+ }
+
+ .details {
+ border-top: 0;
+ border-left: var(--border-style);
+ height: 60cqw;
+ }
+}
diff --git a/app/features/trophies/components/TrophyShowcase.tsx b/app/features/trophies/components/TrophyShowcase.tsx
new file mode 100644
index 000000000..8674af517
--- /dev/null
+++ b/app/features/trophies/components/TrophyShowcase.tsx
@@ -0,0 +1,63 @@
+import clsx from "clsx";
+import type * as React from "react";
+import { useTranslation } from "react-i18next";
+import { Link } from "react-router";
+import { SendouDialog } from "~/components/elements/Dialog";
+import { trophyPage } from "~/utils/urls";
+import { Trophy } from "./Trophy";
+import styles from "./TrophyShowcase.module.css";
+
+export function TrophyShowcase({
+ model,
+ children,
+ className,
+ detailsClassName,
+}: {
+ model: string;
+ children: React.ReactNode;
+ className?: string;
+ detailsClassName?: string;
+}) {
+ return (
+
+ );
+}
+
+export function TrophyShowcaseModal({
+ trophy,
+ onClose,
+ children,
+}: {
+ trophy: { id: number; name: string; model: string };
+ onClose: () => void;
+ children?: React.ReactNode;
+}) {
+ const { t } = useTranslation(["trophies"]);
+
+ return (
+
+
+
+
{trophy.name}
+
+ {t("trophies:display.viewTrophyPage")}
+
+
+ {children}
+
+
+ );
+}
diff --git a/app/features/trophies/components/TrophyTournamentHistory.module.css b/app/features/trophies/components/TrophyTournamentHistory.module.css
new file mode 100644
index 000000000..bc29f5488
--- /dev/null
+++ b/app/features/trophies/components/TrophyTournamentHistory.module.css
@@ -0,0 +1,11 @@
+.list {
+ display: flex;
+ flex-direction: column;
+ padding: 0;
+ margin: 0;
+ gap: var(--s-1);
+
+ & > li {
+ list-style: none;
+ }
+}
diff --git a/app/features/trophies/components/TrophyTournamentHistory.tsx b/app/features/trophies/components/TrophyTournamentHistory.tsx
new file mode 100644
index 000000000..71924cfca
--- /dev/null
+++ b/app/features/trophies/components/TrophyTournamentHistory.tsx
@@ -0,0 +1,19 @@
+import type { TrophyTournamentsLoaderData } from "../routes/trophies.$id.tournaments";
+import { TournamentSummaryRow } from "./TournamentSummaryRow";
+import styles from "./TrophyTournamentHistory.module.css";
+
+export function TrophyTournamentHistory({
+ tournaments,
+}: {
+ tournaments: TrophyTournamentsLoaderData["tournaments"];
+}) {
+ return (
+
+ {tournaments.map((tournament) => (
+
+
+
+ ))}
+
+ );
+}
diff --git a/app/features/trophies/core/model-analysis.ts b/app/features/trophies/core/model-analysis.ts
new file mode 100644
index 000000000..83eca936e
--- /dev/null
+++ b/app/features/trophies/core/model-analysis.ts
@@ -0,0 +1,110 @@
+import type { Color3, ExtrasOptions, ViewerSettings } from "picocad2-web";
+
+export interface TrophyModelAnalysis {
+ cameraTargetCentered: boolean;
+ backgroundIsAlpha: boolean;
+ drawCalls: number;
+ polyCount: number;
+ effectsCount: number;
+}
+
+type RawColor = Array | { r: number; g: number; b: number };
+
+interface RawFace {
+ vertex_ids: Array;
+ dbl?: boolean;
+ prio?: boolean;
+}
+
+interface RawGraphNode {
+ visible: boolean;
+ children: Array;
+ mesh?: { faces: Array };
+}
+
+interface ModelState {
+ source: {
+ graph: RawGraphNode;
+ texture: {
+ colors: Array;
+ background_color: number;
+ transparent_color: number;
+ };
+ };
+ settings: ViewerSettings;
+ extras?: ExtrasOptions;
+}
+
+export function analyzeTrophyModel(model: string): TrophyModelAnalysis | null {
+ try {
+ const state: ModelState = JSON.parse(model);
+
+ return {
+ cameraTargetCentered: isCameraTargetCentered(state),
+ backgroundIsAlpha: isBackgroundAlpha(state),
+ ...countGeometry(state),
+ effectsCount: countEnabledEffects(state),
+ };
+ } catch {
+ return null;
+ }
+}
+
+function isCameraTargetCentered(state: ModelState) {
+ const target = state.settings.camera.target;
+ return target[0] === 0 && target[2] === 0;
+}
+
+function isBackgroundAlpha(state: ModelState) {
+ const texture = state.source.texture;
+ const transparent = toColor3(texture.colors[texture.transparent_color]);
+ const background =
+ state.settings.backgroundColor ??
+ toColor3(texture.colors[texture.background_color]);
+
+ return (
+ background[0] === transparent[0] &&
+ background[1] === transparent[1] &&
+ background[2] === transparent[2]
+ );
+}
+
+function toColor3(color: RawColor): Color3 {
+ return Array.isArray(color)
+ ? [color[0], color[1], color[2]]
+ : [color.r, color.g, color.b];
+}
+
+function countGeometry(state: ModelState) {
+ const counts = { drawCalls: 0, polyCount: 0 };
+
+ const visit = (node: RawGraphNode, parentVisible: boolean) => {
+ for (const child of node.children) {
+ const visible = Boolean(child.visible) && parentVisible;
+
+ if (visible && child.mesh) {
+ const groups = new Set();
+
+ for (const face of child.mesh.faces) {
+ if (face.vertex_ids.length < 3) continue;
+
+ groups.add((face.dbl ? 1 : 0) | (face.prio ? 2 : 0));
+ counts.polyCount += face.vertex_ids.length - 2;
+ }
+
+ counts.drawCalls += groups.size;
+ }
+
+ visit(child, visible);
+ }
+ };
+ visit(state.source.graph, true);
+
+ return counts;
+}
+
+function countEnabledEffects(state: ModelState) {
+ return Object.values(state.extras ?? {}).filter(
+ (effect) => effect?.enabled === true,
+ ).length;
+}
diff --git a/app/features/trophies/loaders/trophies.$id.server.ts b/app/features/trophies/loaders/trophies.$id.server.ts
new file mode 100644
index 000000000..59dd2e2c5
--- /dev/null
+++ b/app/features/trophies/loaders/trophies.$id.server.ts
@@ -0,0 +1,27 @@
+import type { LoaderFunctionArgs } from "react-router";
+import { getUser } from "~/features/auth/core/user.server";
+import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
+import { idObject } from "~/utils/zod";
+import * as TrophyRepository from "../TrophyRepository.server";
+import { canAccessTrophies } from "../trophies-utils";
+
+export const loader = async ({ params }: LoaderFunctionArgs) => {
+ if (!canAccessTrophies(getUser())) {
+ throw new Response(null, { status: 404 });
+ }
+
+ const { id } = parseParams({
+ params,
+ schema: idObject,
+ });
+
+ const [trophy, tournaments] = await Promise.all([
+ TrophyRepository.findById(id).then(notFoundIfNullish),
+ TrophyRepository.findTournamentsByTrophyId(id),
+ ]);
+
+ return {
+ trophy,
+ tournaments,
+ };
+};
diff --git a/app/features/trophies/loaders/trophies.new.server.ts b/app/features/trophies/loaders/trophies.new.server.ts
new file mode 100644
index 000000000..59d8f5809
--- /dev/null
+++ b/app/features/trophies/loaders/trophies.new.server.ts
@@ -0,0 +1,67 @@
+import type { LoaderFunctionArgs } from "react-router";
+import { requireUser } from "~/features/auth/core/user.server";
+import type { SerializeFrom } from "~/utils/remix";
+import * as TrophyRepository from "../TrophyRepository.server";
+import { TROPHY_APPROVALS_REQUIRED } from "../trophies-constants";
+import {
+ canAccessTrophies,
+ canEditAnyTrophy,
+ canReviewTrophies,
+} from "../trophies-utils";
+
+export type NewTrophyLoaderData = SerializeFrom;
+
+export const loader = async (_args: LoaderFunctionArgs) => {
+ const user = requireUser();
+ if (!canAccessTrophies(user)) {
+ throw new Response(null, { status: 404 });
+ }
+
+ const canReview = canReviewTrophies(user);
+
+ const [rawItems, ownUnreviewedCount, editableTrophies] = await Promise.all([
+ canReview
+ ? TrophyRepository.allPending()
+ : TrophyRepository.pendingBySubmitter(user.id),
+ TrophyRepository.unreviewedCountBySubmitter(user.id),
+ canEditAnyTrophy(user)
+ ? TrophyRepository.findAllForEditing()
+ : TrophyRepository.findManagedBy(user.id),
+ ]);
+
+ const allItems = canReview ? rawItems : rawItems.map(stripReviewerInfo);
+
+ const isAccepted = (item: (typeof allItems)[number]) =>
+ item.approvals.length >= TROPHY_APPROVALS_REQUIRED;
+
+ const pendingTrophies = allItems.filter(
+ (item) => !isAccepted(item) && !item.declinedAt,
+ );
+ const reviewedTrophies = allItems.filter(
+ (item) => isAccepted(item) || item.declinedAt,
+ );
+
+ return {
+ canReview,
+ currentUserId: user.id,
+ ownUnreviewedCount,
+ pendingTrophies,
+ reviewedTrophies,
+ editableTrophies,
+ };
+};
+
+function stripReviewerInfo(
+ item: Awaited>[number],
+) {
+ return {
+ ...item,
+ approvals: item.approvals.map(() => ({
+ userId: null as number | null,
+ username: null as string | null,
+ createdAt: 0,
+ })),
+ declinedByUserId: null,
+ declinedByUsername: null,
+ };
+}
diff --git a/app/features/trophies/loaders/trophies.server.ts b/app/features/trophies/loaders/trophies.server.ts
new file mode 100644
index 000000000..34f94d587
--- /dev/null
+++ b/app/features/trophies/loaders/trophies.server.ts
@@ -0,0 +1,28 @@
+import { cachified } from "@epic-web/cachified";
+import { getUser } from "~/features/auth/core/user.server";
+import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server";
+import * as TrophyRepository from "../TrophyRepository.server";
+import { canAccessTrophies } from "../trophies-utils";
+
+const TROPHIES_CACHE_KEY = "trophies";
+
+export const loader = async () => {
+ if (!canAccessTrophies(getUser())) {
+ throw new Response(null, { status: 404 });
+ }
+
+ const trophies = await cachified({
+ key: TROPHIES_CACHE_KEY,
+ cache,
+ ttl: ttl(IN_MILLISECONDS.TWO_HOURS),
+ async getFreshValue() {
+ return TrophyRepository.all();
+ },
+ });
+
+ return { trophies };
+};
+
+export function clearTrophiesCache() {
+ cache.delete(TROPHIES_CACHE_KEY);
+}
diff --git a/app/features/trophies/routes/trophies.$id.tournaments.ts b/app/features/trophies/routes/trophies.$id.tournaments.ts
new file mode 100644
index 000000000..f668fd515
--- /dev/null
+++ b/app/features/trophies/routes/trophies.$id.tournaments.ts
@@ -0,0 +1,24 @@
+import type { LoaderFunctionArgs } from "react-router";
+import { getUser } from "~/features/auth/core/user.server";
+import type { SerializeFrom } from "~/utils/remix";
+import { parseParams } from "~/utils/remix.server";
+import { idObject } from "~/utils/zod";
+import * as TrophyRepository from "../TrophyRepository.server";
+import { canAccessTrophies } from "../trophies-utils";
+
+export type TrophyTournamentsLoaderData = SerializeFrom;
+
+export const loader = async ({ params }: LoaderFunctionArgs) => {
+ if (!canAccessTrophies(getUser())) {
+ throw new Response(null, { status: 404 });
+ }
+
+ const { id } = parseParams({
+ params,
+ schema: idObject,
+ });
+
+ return {
+ tournaments: await TrophyRepository.findTournamentsByTrophyId(id),
+ };
+};
diff --git a/app/features/trophies/routes/trophies.$id.tsx b/app/features/trophies/routes/trophies.$id.tsx
new file mode 100644
index 000000000..364431e0b
--- /dev/null
+++ b/app/features/trophies/routes/trophies.$id.tsx
@@ -0,0 +1,145 @@
+import clsx from "clsx";
+import { Trans, useTranslation } from "react-i18next";
+import { Link, type MetaFunction, useLoaderData } from "react-router";
+import { Divider } from "~/components/Divider";
+import { metaTags, type SerializeFrom } from "~/utils/remix";
+import { tournamentOrganizationPage, userPage } from "~/utils/urls";
+import { TrophyShowcase } from "../components/TrophyShowcase";
+import { TrophyTournamentHistory } from "../components/TrophyTournamentHistory";
+import { loader } from "../loaders/trophies.$id.server";
+import { parseSpecialTrophyCode } from "../trophies-utils";
+import styles from "./trophies.module.css";
+
+export { loader };
+
+export const meta: MetaFunction = (args) => {
+ const data = args.loaderData as SerializeFrom | null;
+
+ if (!data) return [];
+
+ const ownerCount = data.trophy.owners.reduce(
+ (sum, owner) => sum + owner.count,
+ 0,
+ );
+
+ return metaTags({
+ title: data.trophy.name,
+ ogTitle: `${data.trophy.name} (Splatoon trophy)`,
+ description: `See who owns the ${data.trophy.name} trophy on sendou.ink. Awarded ${ownerCount} time${ownerCount === 1 ? "" : "s"} so far.`,
+ location: args.location,
+ });
+};
+
+export default function TrophyDetailsPage() {
+ const { t } = useTranslation(["trophies"]);
+ const data = useLoaderData();
+ const { trophy, tournaments } = data;
+
+ const special = parseSpecialTrophyCode(trophy.code);
+
+ return (
+
+
+
{trophy.name}
+ {trophy.organization ? (
+
+
+ Organization:
+
+ {trophy.organization.name}
+
+
+
+ ) : null}
+ {trophy.creator ? (
+
+
+ Created by
+
+ {trophy.creator.username}
+
+
+
+ ) : null}
+ {trophy.manager ? (
+
+
+ Managed by
+
+ {trophy.manager.username}
+
+
+
+ ) : null}
+ {special ? (
+
+ {special.type === "supporter"
+ ? t("trophies:special.supporter.description")
+ : t("trophies:special.xp.description", {
+ value: special.value,
+ })}
+
+ ) : null}
+
+ {special ? null : (
+
+
+ {t("trophies:details.tournamentHistory")}
+
+ {tournaments.length > 0 ? (
+
+ ) : (
+
+ {t("trophies:details.noTournamentHistory")}
+
+ )}
+
+ )}
+
+
+ {t("trophies:details.owners")}
+
+ {trophy.owners.length > 0 ? (
+
+ {trophy.owners.map((owner) => (
+
+ {owner.username}
+
+ ×{owner.count}
+
+
+ ))}
+
+ ) : (
+
+ {t("trophies:details.noOwners")}
+
+ )}
+
+
+ );
+}
diff --git a/app/features/trophies/routes/trophies.$id.wins.$userId.ts b/app/features/trophies/routes/trophies.$id.wins.$userId.ts
new file mode 100644
index 000000000..c7e7ed498
--- /dev/null
+++ b/app/features/trophies/routes/trophies.$id.wins.$userId.ts
@@ -0,0 +1,33 @@
+import type { LoaderFunctionArgs } from "react-router";
+import * as R from "remeda";
+import { z } from "zod";
+import { getUser } from "~/features/auth/core/user.server";
+import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
+import type { SerializeFrom } from "~/utils/remix";
+import { parseParams } from "~/utils/remix.server";
+import { id } from "~/utils/zod";
+import * as TrophyRepository from "../TrophyRepository.server";
+import { canAccessTrophies } from "../trophies-utils";
+
+export type TrophyWinsLoaderData = SerializeFrom;
+
+const paramsSchema = z.object({ id, userId: id });
+
+export const loader = async ({ params }: LoaderFunctionArgs) => {
+ if (!canAccessTrophies(getUser())) {
+ throw new Response(null, { status: 404 });
+ }
+
+ const { id: trophyId, userId } = parseParams({
+ params,
+ schema: paramsSchema,
+ });
+
+ const wins = await TrophyRepository.findWinsByOwner({ trophyId, userId });
+
+ const { userCards } = await UserCardRepository.findAllByUserIds({
+ userIds: R.unique(wins.flatMap((win) => win.members.map((m) => m.id))),
+ });
+
+ return { wins, userCards };
+};
diff --git a/app/features/trophies/routes/trophies.module.css b/app/features/trophies/routes/trophies.module.css
new file mode 100644
index 000000000..4bcdb1417
--- /dev/null
+++ b/app/features/trophies/routes/trophies.module.css
@@ -0,0 +1,64 @@
+.mainContent {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-6);
+}
+
+.badgesLink {
+ color: var(--color-text-high);
+ font-size: var(--font-xs);
+}
+
+.trophiesListContainer {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-4);
+}
+
+.trophyDetailsContainer {
+ border: var(--border-style);
+ border-radius: var(--radius-box);
+ overflow: clip;
+}
+
+@container (width < 560px) {
+ .trophyDetails {
+ height: 512px;
+ }
+}
+
+.divider {
+ margin-top: var(--s-1);
+}
+
+.trophyName {
+ font-weight: var(--weight-bold);
+ font-size: var(--font-lg);
+}
+
+.trophyMeta {
+ color: var(--color-text-high);
+ font-size: var(--font-2xs);
+}
+
+.owners {
+ display: flex;
+ flex-wrap: wrap;
+ padding: 0;
+ font-size: var(--font-xs);
+ gap: var(--s-2);
+
+ & > li {
+ display: flex;
+ align-items: center;
+ gap: var(--s-1);
+ list-style: none;
+ border-right: var(--border-style);
+ padding-right: var(--s-2);
+ }
+}
+
+.ownerCount {
+ color: var(--color-text-high);
+ font-size: var(--font-2xs);
+}
diff --git a/app/features/trophies/routes/trophies.new.module.css b/app/features/trophies/routes/trophies.new.module.css
new file mode 100644
index 000000000..749e05dfe
--- /dev/null
+++ b/app/features/trophies/routes/trophies.new.module.css
@@ -0,0 +1,323 @@
+.terms {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-4);
+ padding: var(--s-4);
+ border: var(--border-style);
+ border-radius: var(--radius-box);
+ background-color: var(--color-bg-high);
+ font-size: var(--font-sm);
+}
+
+.termsTitle {
+ font-size: var(--font-md);
+ color: var(--color-error-high);
+ background-color: var(--color-error-low);
+ padding: var(--s-2);
+ border-radius: var(--radius-field);
+ border: 1px solid var(--color-error);
+}
+
+.termsGroupTitle {
+ font-weight: var(--weight-semi);
+ margin-bottom: var(--s-1);
+}
+
+.termsList {
+ list-style: disc;
+ padding-inline-start: var(--s-6);
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-1);
+}
+
+.termsDisclaimer {
+ font-weight: var(--weight-bold);
+ margin-bottom: var(--s-1);
+}
+
+.termsAgreeButton {
+ align-self: center;
+}
+
+.modelTextarea {
+ min-height: 160px;
+ font-family: var(--font-mono);
+ font-size: var(--font-xs);
+}
+
+.trophyPreview {
+ border-radius: var(--radius-field);
+ background-color: var(--color-bg-high);
+ margin-top: var(--s-4);
+
+ &:has(div) {
+ width: 100px;
+ height: auto;
+ aspect-ratio: 1 / 1;
+ border: var(--border-width) solid var(--tier-bg);
+ }
+}
+
+.previewThemes {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--s-4);
+ margin-top: var(--s-4);
+}
+
+.previewTheme {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--s-3);
+ padding: var(--s-3);
+ border: var(--border-style);
+ border-radius: var(--radius-field);
+ background-color: var(--color-bg);
+
+ & .trophyPreview {
+ margin-top: 0;
+ width: 100%;
+ }
+}
+
+.previewThemeLabel {
+ font-size: var(--font-xs);
+ font-weight: var(--weight-semi);
+ color: var(--color-text-high);
+}
+
+.modelSpecs {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-3);
+ margin-top: var(--s-4);
+ padding: var(--s-3);
+ border: var(--border-style);
+ border-radius: var(--radius-box);
+ background-color: var(--color-bg-high);
+ font-size: var(--font-sm);
+}
+
+.specList {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-1);
+ padding: 0;
+ margin-top: var(--s-1);
+
+ & > li {
+ list-style: none;
+ }
+}
+
+.specItem {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--s-1-5);
+}
+
+.specIcon {
+ width: 16px;
+ height: 16px;
+ flex-shrink: 0;
+ margin-top: 2px;
+ color: var(--color-text-high);
+}
+
+.specIconPass {
+ color: var(--color-success);
+}
+
+.specIconFail {
+ color: var(--color-error);
+}
+
+.specIconWarn {
+ color: var(--color-warning);
+}
+
+.specDetail {
+ color: var(--color-text-high);
+}
+
+.pendingSpecs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--s-1) var(--s-2);
+ font-size: var(--font-2xs);
+ color: var(--color-text-high);
+}
+
+.pendingSpecsWarn {
+ color: var(--color-warning);
+ font-weight: var(--weight-semi);
+}
+
+.pendingSpecsError {
+ color: var(--color-error);
+ font-weight: var(--weight-semi);
+}
+
+.descriptionTextarea {
+ min-height: 120px;
+}
+
+.updateContainer {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-4);
+}
+
+.diffHeader {
+ font-size: var(--font-2xs);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.diffGrid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--s-4) var(--s-2);
+ padding: var(--s-2);
+ border: var(--border-style);
+ border-radius: var(--radius-field);
+ background-color: var(--color-bg);
+ font-size: var(--font-xs);
+}
+
+.diffField {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.diffLabel {
+ color: var(--color-text-high);
+ font-size: var(--font-2xs);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.diffValue {
+ overflow-wrap: anywhere;
+}
+
+.diffOld {
+ color: var(--color-text-high);
+ text-decoration: line-through;
+}
+
+.editingBadge {
+ padding: 0 var(--s-1-5);
+ border-radius: var(--radius-selector);
+ background-color: var(--color-text-accent);
+ color: var(--color-text-inverse);
+ font-size: var(--font-2xs);
+ font-weight: var(--weight-bold);
+}
+
+.notManagerBadge {
+ padding: 0 var(--s-1-5);
+ border-radius: var(--radius-selector);
+ background-color: var(--color-warning-low);
+ color: var(--color-warning-high);
+ font-size: var(--font-2xs);
+ font-weight: var(--weight-bold);
+}
+
+.pendingList {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-4);
+}
+
+.pendingItem {
+ display: grid;
+ grid-template-columns: 100px 1fr;
+ gap: var(--s-4);
+ padding: var(--s-2);
+ border: var(--border-style);
+ border-radius: var(--radius-box);
+ background-color: var(--color-bg-high);
+ opacity: 1;
+ transition: opacity 0.2s ease;
+
+ @starting-style {
+ opacity: 0;
+ }
+}
+
+.pendingMain {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-2);
+ min-width: 0;
+}
+
+.pendingHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--s-2);
+ flex-wrap: wrap;
+}
+
+.pendingName {
+ font-weight: var(--weight-semi);
+}
+
+.pendingMeta {
+ font-size: var(--font-xs);
+ color: var(--color-text-high);
+}
+
+.pendingDescription {
+ white-space: pre-wrap;
+ font-size: var(--font-sm);
+}
+
+.pendingActions {
+ display: flex;
+ gap: var(--s-2);
+ flex-wrap: wrap;
+}
+
+.declined {
+ background-color: var(--color-error-low);
+ border: 1px solid var(--color-error);
+ border-radius: var(--radius-field);
+ padding: var(--s-2);
+ font-size: var(--font-sm);
+
+ > p {
+ color: var(--color-error-high);
+ font-weight: var(--weight-bold);
+ }
+}
+
+.accepted {
+ background-color: var(--color-success-low);
+ border: 1px solid var(--color-success);
+ border-radius: var(--radius-field);
+ padding: var(--s-2);
+ font-size: var(--font-sm);
+
+ > p {
+ color: var(--color-success-high);
+ font-weight: var(--weight-bold);
+ }
+}
+
+.trophyPreviewButton {
+ all: unset;
+ cursor: pointer;
+}
+
+.dialogForm {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-3);
+ min-width: 280px;
+}
diff --git a/app/features/trophies/routes/trophies.new.tsx b/app/features/trophies/routes/trophies.new.tsx
new file mode 100644
index 000000000..89ad26f39
--- /dev/null
+++ b/app/features/trophies/routes/trophies.new.tsx
@@ -0,0 +1,966 @@
+import clsx from "clsx";
+import { Check, Clipboard, Dot, Trash2, TriangleAlert, X } from "lucide-react";
+import * as React from "react";
+import { Trans, useTranslation } from "react-i18next";
+import {
+ Form,
+ Link,
+ type MetaFunction,
+ useFetcher,
+ useLoaderData,
+} from "react-router";
+import { Alert } from "~/components/Alert";
+import { SendouButton } from "~/components/elements/Button";
+import { SendouDialog } from "~/components/elements/Dialog";
+import { OrganizationSearch } from "~/components/elements/OrganizationSearch";
+import { SendouSelect, SendouSelectItem } from "~/components/elements/Select";
+import {
+ SendouTab,
+ SendouTabList,
+ SendouTabPanel,
+ SendouTabs,
+} from "~/components/elements/Tabs";
+import { UserSearch } from "~/components/elements/UserSearch";
+import { FormMessage } from "~/components/FormMessage";
+import { Label } from "~/components/Label";
+import { Main } from "~/components/Main";
+import type { CustomFieldRenderProps } from "~/form/FormField";
+import { SendouForm } from "~/form/SendouForm";
+import { useDebounce } from "~/hooks/useDebounce";
+import { metaTags } from "~/utils/remix";
+import type { SendouRouteHandle } from "~/utils/remix.server";
+import {
+ navIconUrl,
+ PICOCAD2_WEB_VIEWER_URL,
+ SENDOU_INK_DISCORD_URL,
+ TROPHIES_PAGE,
+ tournamentOrganizationPage,
+ userPage,
+} from "~/utils/urls";
+import { action } from "../actions/trophies.new.server";
+import { Trophy, TrophyContextProvider } from "../components/Trophy";
+import {
+ analyzeTrophyModel,
+ type TrophyModelAnalysis,
+} from "../core/model-analysis";
+import {
+ loader,
+ type NewTrophyLoaderData,
+} from "../loaders/trophies.new.server";
+import {
+ TROPHY_APPROVALS_REQUIRED,
+ TROPHY_DECLINE_REASON_MAX_LENGTH,
+ TROPHY_MODEL_RECOMMENDED_MAX_DRAW_CALLS,
+ TROPHY_MODEL_RECOMMENDED_MAX_EFFECTS,
+ TROPHY_MODEL_RECOMMENDED_MAX_POLYS,
+ TROPHY_PENDING_PER_USER_LIMIT,
+} from "../trophies-constants";
+import {
+ createTrophyFormSchema,
+ updateTrophyFormSchema,
+} from "../trophies-schemas";
+import {
+ compressTrophyModel,
+ decompressTrophyModel,
+ useProgressiveRender,
+ useTrophyTermsAgreement,
+} from "../trophies-utils";
+import styles from "./trophies.new.module.css";
+
+export { action, loader };
+
+export const handle: SendouRouteHandle = {
+ i18n: "trophies",
+ breadcrumb: () => ({
+ imgPath: navIconUrl("trophies"),
+ href: TROPHIES_PAGE,
+ type: "IMAGE",
+ }),
+};
+
+export const meta: MetaFunction = (args) => {
+ return metaTags({
+ title: "New trophy",
+ ogTitle: "Submit a new trophy",
+ location: args.location,
+ description: "Submit a new trophy for review.",
+ });
+};
+
+export default function NewTrophyPage() {
+ const { t } = useTranslation(["trophies"]);
+ const data = useLoaderData();
+
+ return (
+
+
+
+ {t("trophies:new.tabs.upload")}
+
+ {t("trophies:new.tabs.update")}
+
+
+ {t("trophies:new.tabs.pending")}
+
+ {t("trophies:new.tabs.reviewed")}
+
+
+ {data.ownUnreviewedCount >= TROPHY_PENDING_PER_USER_LIMIT ? (
+
+ {t("trophies:new.form.limitReached", {
+ limit: TROPHY_PENDING_PER_USER_LIMIT,
+ })}
+
+ ) : (
+
+
+
+ )}
+
+
+ {data.ownUnreviewedCount >= TROPHY_PENDING_PER_USER_LIMIT ? (
+
+ {t("trophies:new.form.limitReached", {
+ limit: TROPHY_PENDING_PER_USER_LIMIT,
+ })}
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function TrophyTermsGate({ children }: { children: React.ReactNode }) {
+ const { t } = useTranslation(["trophies"]);
+ const { hasAgreedToTerms, agreeToTerms } = useTrophyTermsAgreement();
+
+ if (hasAgreedToTerms) return children;
+
+ return (
+
+
{t("trophies:new.terms.title")}
+
{t("trophies:new.terms.intro")}
+
+
+ {t("trophies:new.terms.oneOff.title")}
+
+
+ {t("trophies:new.terms.trustedOrg")}
+ {t("trophies:new.terms.oneOff.pastTournaments")}
+ {t("trophies:new.terms.oneOff.projectedTeams")}
+ {t("trophies:new.terms.oneOff.signedUpTeams")}
+
+
+
+
+ {t("trophies:new.terms.series.title")}
+
+
+ {t("trophies:new.terms.trustedOrg")}
+ {t("trophies:new.terms.series.consistentTeams")}
+
+
+
+
+ {t("trophies:new.terms.disclaimer")}
+
+
+
+ If you are unsure if your tournament is eligible, feel free to
+ acquire a pre-approval by creating a pre-approval request in the
+
+ Discord server
+
+ .
+
+
+
+
+ {t("trophies:new.terms.agree")}
+
+
+ );
+}
+
+function NewTrophyForm() {
+ return (
+
+ {({ FormField }) => (
+ <>
+
+
+ {({ error, value, onChange }: CustomFieldRenderProps) => (
+
+ )}
+
+
+ {({ name, error, value, onChange }: CustomFieldRenderProps) => (
+
+ )}
+
+
+ >
+ )}
+
+ );
+}
+
+function UpdateTrophyTab() {
+ const { t } = useTranslation(["trophies"]);
+ const data = useLoaderData();
+ const [selectedId, setSelectedId] = React.useState(null);
+
+ const selectedTrophy = data.editableTrophies.find((t) => t.id === selectedId);
+
+ return (
+
+ setSelectedId(key as number)}
+ >
+ {(trophy) => (
+
+ {trophy.name}
+
+ )}
+
+ {selectedTrophy ? (
+
+ ) : null}
+
+ );
+}
+
+function UpdateTrophyForm({
+ trophy,
+}: {
+ trophy: NewTrophyLoaderData["editableTrophies"][number];
+}) {
+ const decompressedModel = decompressTrophyModel(trophy.model) ?? "";
+
+ return (
+
+ {({ FormField }) => (
+ <>
+
+
+ {({ error, value, onChange }: CustomFieldRenderProps) => (
+
+ )}
+
+
+ {({ error, value, onChange }: CustomFieldRenderProps) => (
+
+ )}
+
+
+ {({ name, error, value, onChange }: CustomFieldRenderProps) => (
+
+ )}
+
+
+ >
+ )}
+
+ );
+}
+
+function ManagerField({
+ error,
+ value,
+ onChange,
+}: {
+ error?: string;
+ value: number | null;
+ onChange: (value: number | null) => void;
+}) {
+ const { t } = useTranslation(["forms"]);
+
+ return (
+
+ {t("forms:labels.trophyManager")}
+ onChange(user?.id ?? null)}
+ />
+ {error ? {error} : null}
+
+ );
+}
+
+function ModelField({
+ name,
+ error,
+ value,
+ onChange,
+}: {
+ name: string;
+ error?: string;
+ value: string;
+ onChange: (value: string) => void;
+}) {
+ const { t } = useTranslation(["forms", "trophies"]);
+ const [preview, setPreview] = React.useState(() => buildModelPreview(value));
+
+ useDebounce(() => setPreview(buildModelPreview(value)), 500, [value]);
+
+ return (
+
+
+ {t("forms:labels.trophyModel")}
+
+
+ );
+}
+
+function buildModelPreview(model: string) {
+ if (!model) return { compressedModel: "", analysis: null };
+
+ return {
+ compressedModel: compressTrophyModel(model),
+ analysis: analyzeTrophyModel(model),
+ };
+}
+
+function ModelSpecs({ analysis }: { analysis: TrophyModelAnalysis | null }) {
+ const { t } = useTranslation(["trophies"]);
+
+ const enforcedStatus = (passes: boolean) =>
+ analysis ? (passes ? "pass" : "fail") : null;
+ const recommendedStatus = (withinLimit: boolean) =>
+ analysis ? (withinLimit ? "pass" : "warn") : null;
+
+ return (
+
+
+
+ {t("trophies:new.specs.required")}
+
+
+
+ {t("trophies:new.specs.cameraTarget")}
+
+
+ {t("trophies:new.specs.background")}
+
+ {t("trophies:new.specs.centered")}
+ {t("trophies:new.specs.zoom")}
+ {t("trophies:new.specs.angles")}
+
+
+
+
+ {t("trophies:new.specs.recommended")}
+
+
+
+ {t("trophies:new.specs.drawCalls", {
+ value: TROPHY_MODEL_RECOMMENDED_MAX_DRAW_CALLS,
+ })}
+
+
+ {t("trophies:new.specs.polys", {
+ value: TROPHY_MODEL_RECOMMENDED_MAX_POLYS,
+ })}
+
+
+ {t("trophies:new.specs.effects", {
+ value: TROPHY_MODEL_RECOMMENDED_MAX_EFFECTS,
+ })}
+
+
+
+
+ );
+}
+
+function SpecItem({
+ children,
+ status,
+ detail,
+}: {
+ children: React.ReactNode;
+ status?: "pass" | "fail" | "warn" | null;
+ detail?: string;
+}) {
+ return (
+
+ {status === "pass" ? (
+
+ ) : status === "fail" ? (
+
+ ) : status === "warn" ? (
+
+ ) : (
+
+ )}
+
+ {children}
+ {detail ? {detail} : null}
+
+
+ );
+}
+
+function OrganizationField({
+ error,
+ value,
+ onChange,
+}: {
+ error?: string;
+ value: number | null;
+ onChange: (value: number | null) => void;
+}) {
+ const { t } = useTranslation(["forms"]);
+
+ return (
+
+ {t("forms:labels.trophyOrganization")}
+ onChange(org?.id ?? null)}
+ />
+ {error ? {error} : null}
+
+ );
+}
+
+function TrophyList({
+ items,
+ listKind,
+}: {
+ items: NewTrophyLoaderData["pendingTrophies"];
+ listKind: "pending" | "reviewed";
+}) {
+ const { t } = useTranslation(["trophies"]);
+ const data = useLoaderData();
+ const visibleCount = useProgressiveRender(items.length, listKind);
+
+ if (items.length === 0) {
+ return (
+
+ {listKind === "pending"
+ ? t("trophies:new.pending.empty")
+ : t("trophies:new.reviewed.empty")}
+
+ );
+ }
+
+ return (
+
+
+ {items.slice(0, visibleCount).map((item) => (
+
+ ))}
+
+
+ );
+}
+
+function TrophyListRow({
+ pending,
+ currentUserId,
+ canReview,
+}: {
+ pending: NewTrophyLoaderData["pendingTrophies"][number];
+ currentUserId: number;
+ canReview: boolean;
+}) {
+ const { t } = useTranslation(["trophies", "common"]);
+ const fetcher = useFetcher();
+
+ const isOwner = pending.submitterUserId === currentUserId;
+ const isDeclined = pending.declinedAt !== null;
+ const isAccepted = pending.approvals.length >= TROPHY_APPROVALS_REQUIRED;
+ const isReviewed = isDeclined || isAccepted;
+ const alreadyApproved = pending.approvals.some(
+ (a) => a.userId === currentUserId,
+ );
+
+ const handleDelete = () => {
+ const formData = new FormData();
+ formData.append("_action", "DELETE");
+ formData.append("pendingTrophyId", String(pending.id));
+ fetcher.submit(formData, { method: "post" });
+ };
+
+ const handleApprove = () => {
+ const formData = new FormData();
+ formData.append("_action", "APPROVE");
+ formData.append("pendingTrophyId", String(pending.id));
+ fetcher.submit(formData, { method: "post" });
+ };
+
+ const [previewOpen, setPreviewOpen] = React.useState(false);
+
+ const [analysis] = React.useState(() =>
+ analyzeTrophyModel(decompressTrophyModel(pending.model) ?? ""),
+ );
+
+ return (
+
+
setPreviewOpen(true)}
+ >
+
+
+
setPreviewOpen(false)}
+ showCloseButton
+ >
+
+
+
+
+ {pending.target ? (
+
+ {t("trophies:new.update.editing")}
+
+ ) : null}
+ {pending.target &&
+ pending.submitterUserId !== pending.target.managerId ? (
+
+ {t("trophies:new.pending.notFromManager")} (
+ {pending.submitterUsername})
+
+ ) : null}
+ {pending.name}
+
+ {pending.manager?.discordId ? (
+
+ {pending.manager.username}
+
+ ) : pending.submitterDiscordId ? (
+
+ {pending.submitterUsername}
+
+ ) : (
+ pending.submitterUsername
+ )}
+ {pending.organizationName ? (
+ <>
+ {" • "}
+ {pending.organizationSlug ? (
+
+ {pending.organizationName}
+
+ ) : (
+ pending.organizationName
+ )}
+ >
+ ) : null}
+
+
+ {analysis ? (
+
+ TROPHY_MODEL_RECOMMENDED_MAX_DRAW_CALLS,
+ })}
+ >
+ {t("trophies:new.specs.stats.drawCalls", {
+ value: analysis.drawCalls,
+ })}
+
+ TROPHY_MODEL_RECOMMENDED_MAX_POLYS,
+ })}
+ >
+ {t("trophies:new.specs.stats.polys", {
+ value: analysis.polyCount,
+ })}
+
+ TROPHY_MODEL_RECOMMENDED_MAX_EFFECTS,
+ })}
+ >
+ {t("trophies:new.specs.stats.effects", {
+ value: analysis.effectsCount,
+ })}
+
+ {!analysis.cameraTargetCentered ? (
+
+ {t("trophies:new.specs.stats.cameraTargetOff")}
+
+ ) : null}
+ {!analysis.backgroundIsAlpha ? (
+
+ {t("trophies:new.specs.stats.backgroundNotAlpha")}
+
+ ) : null}
+
+ ) : null}
+ {pending.target ? (
+
+ ) : null}
+ {pending.description ? (
+
{pending.description}
+ ) : null}
+ {pending.approvals.length > 0 && !isDeclined ? (
+
+
+ {t("trophies:new.pending.approvalProgress", {
+ current: pending.approvals.length,
+ required: TROPHY_APPROVALS_REQUIRED,
+ })}
+
+
+ {pending.approvals.some((a) => a.userId === currentUserId)
+ ? `(${pending.approvals.map((a) => a.username).join(", ")})`
+ : ""}
+
+
+ ) : null}
+ {isDeclined ? (
+
+
+ {pending.declinedByUsername
+ ? t("trophies:new.pending.declinedBy", {
+ name: pending.declinedByUsername,
+ })
+ : t("trophies:new.pending.declined")}
+
+
{pending.declineReason}
+
+ ) : null}
+
+ {canReview && !isReviewed ? (
+ <>
+
+ {alreadyApproved
+ ? t("trophies:new.pending.approved")
+ : t("trophies:new.pending.approve")}
+
+
+ }
+ onPress={() =>
+ navigator.clipboard.writeText(
+ decompressTrophyModel(pending.model ?? "{}") ?? "",
+ )
+ }
+ />
+ >
+ ) : null}
+ {isOwner || canReview ? (
+ }
+ />
+ ) : null}
+
+
+
+ );
+}
+
+function DeclineButton({ pendingTrophyId }: { pendingTrophyId: number }) {
+ const { t } = useTranslation(["trophies"]);
+ const [isOpen, setIsOpen] = React.useState(false);
+ const [reason, setReason] = React.useState("");
+ const fetcher = useFetcher();
+ const id = React.useId();
+
+ React.useEffect(() => {
+ if (fetcher.state === "idle" && fetcher.data === null && isOpen) {
+ setIsOpen(false);
+ setReason("");
+ }
+ }, [fetcher.state, fetcher.data, isOpen]);
+
+ return (
+ <>
+ setIsOpen(true)}
+ >
+ {t("trophies:new.pending.decline")}
+
+ {isOpen ? (
+ setIsOpen(false)}
+ showCloseButton
+ >
+
+
+ ) : null}
+ >
+ );
+}
+
+function PendingTrophyDiff({
+ pending,
+ target,
+}: {
+ pending: NewTrophyLoaderData["pendingTrophies"][number];
+ target: NonNullable;
+}) {
+ const { t } = useTranslation(["trophies", "forms"]);
+
+ const newManagerId = pending.managerId ?? pending.submitterUserId;
+ const newManagerName =
+ pending.manager?.username ?? pending.submitterUsername ?? "?";
+
+ const fields: Array<{
+ label: string;
+ oldValue: React.ReactNode;
+ newValue: React.ReactNode;
+ changed: boolean;
+ }> = [
+ {
+ label: t("forms:labels.trophyName"),
+ oldValue: target.name,
+ newValue: pending.name,
+ changed: target.name !== pending.name,
+ },
+ {
+ label: t("forms:labels.trophyOrganization"),
+ oldValue: target.organizationName ?? "—",
+ newValue: pending.organizationName ?? "—",
+ changed: target.organizationId !== pending.organizationId,
+ },
+ {
+ label: t("forms:labels.trophyManager"),
+ oldValue: target.managerUsername ?? "—",
+ newValue: newManagerName,
+ changed: target.managerId !== newManagerId,
+ },
+ {
+ label: t("forms:labels.trophyModel"),
+ oldValue: "-----",
+ newValue: "-----",
+ changed: target.model !== pending.model,
+ },
+ ];
+
+ const changedFields = fields.filter((field) => field.changed);
+ if (changedFields.length === 0) return null;
+
+ return (
+
+
{t("trophies:new.update.before")}
+
{t("trophies:new.update.after")}
+ {changedFields.map((field) => (
+
+
+ {field.label}
+
+ {field.oldValue}
+
+
+
+ {field.label}
+ {field.newValue}
+
+
+ ))}
+
+ );
+}
diff --git a/app/features/trophies/routes/trophies.tsx b/app/features/trophies/routes/trophies.tsx
new file mode 100644
index 000000000..e4eee0ec6
--- /dev/null
+++ b/app/features/trophies/routes/trophies.tsx
@@ -0,0 +1,112 @@
+import { CalendarClock, Search } from "lucide-react";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ Link,
+ type MetaFunction,
+ NavLink,
+ Outlet,
+ useLoaderData,
+} from "react-router";
+import { Input } from "~/components/Input";
+import { Main } from "~/components/Main";
+import type { SendouRouteHandle } from "~/utils/remix.server";
+import { BADGES_PAGE, navIconUrl, TROPHIES_PAGE } from "~/utils/urls";
+import { metaTags } from "../../../utils/remix";
+import {
+ Trophy,
+ TrophyContextProvider,
+ TrophyGrid,
+ TrophyPlaceholder,
+} from "../components/Trophy";
+import { loader } from "../loaders/trophies.server";
+import {
+ hasUpcomingTournamentSoon,
+ useProgressiveRender,
+} from "../trophies-utils";
+import styles from "./trophies.module.css";
+
+export { loader };
+
+export const handle: SendouRouteHandle = {
+ i18n: "trophies",
+ breadcrumb: () => ({
+ imgPath: navIconUrl("trophies"),
+ href: TROPHIES_PAGE,
+ type: "IMAGE",
+ }),
+};
+
+export const meta: MetaFunction = (args) => {
+ return metaTags({
+ title: "Trophies",
+ ogTitle: "Splatoon trophies for tournaments",
+ location: args.location,
+ description:
+ "A full list of all trophies that can be won in Splatoon tournaments.",
+ });
+};
+
+export default function TrophiesPage() {
+ const { t } = useTranslation(["trophies"]);
+ const data = useLoaderData();
+
+ const [inputValue, setInputValue] = useState("");
+ const inputValueNormalized = inputValue.toLowerCase();
+ const filteredTrophies = data.trophies.filter((trophy) =>
+ trophy.name.toLowerCase().includes(inputValueNormalized),
+ );
+ const visibleCount = useProgressiveRender(
+ filteredTrophies.length,
+ inputValue,
+ );
+
+ return (
+
+
+
+
+ }
+ value={inputValue}
+ onChange={(e) => setInputValue(e.target.value)}
+ />
+
+
+ {filteredTrophies.map((trophy, i) =>
+ i < visibleCount ? (
+
+
+ ) : undefined
+ }
+ />
+
+ ) : (
+
+ ),
+ )}
+
+
+
+
+ {t("trophies:lookingForBadges")}{" "}
+ {t("trophies:viewBadges")}
+
+
+
+ );
+}
diff --git a/app/features/trophies/trophies-constants.ts b/app/features/trophies/trophies-constants.ts
new file mode 100644
index 000000000..19c6fa763
--- /dev/null
+++ b/app/features/trophies/trophies-constants.ts
@@ -0,0 +1,25 @@
+export const TROPHY_NAME_MIN_LENGTH = 1;
+export const TROPHY_NAME_MAX_LENGTH = 32;
+
+export const TROPHY_MODEL_MAX_LENGTH = 1024 * 1024;
+export const TROPHY_MODEL_RECOMMENDED_MAX_DRAW_CALLS = 15;
+export const TROPHY_MODEL_RECOMMENDED_MAX_POLYS = 3000;
+export const TROPHY_MODEL_RECOMMENDED_MAX_EFFECTS = 5;
+
+export const TROPHY_APPROVALS_REQUIRED = 2;
+
+export const TROPHY_DESCRIPTION_MAX_LENGTH = 3000;
+
+export const TROPHY_DECLINE_REASON_MIN_LENGTH = 1;
+export const TROPHY_DECLINE_REASON_MAX_LENGTH = 3000;
+
+export const TROPHY_PENDING_PER_USER_LIMIT = 5;
+
+export const SMALL_TROPHIES_PER_DISPLAY_PAGE = 6;
+
+export const TROPHY_UPCOMING_HIGHLIGHT_WEEKS = 4;
+
+export const TROPHIES_RELEASED = false;
+
+export const SUPPORTER_TROPHY_CODE = "supporter";
+export const XP_TROPHY_CODE_PREFIX = "xp-";
diff --git a/app/features/trophies/trophies-schemas.ts b/app/features/trophies/trophies-schemas.ts
new file mode 100644
index 000000000..c1f10d765
--- /dev/null
+++ b/app/features/trophies/trophies-schemas.ts
@@ -0,0 +1,106 @@
+import { z } from "zod";
+import {
+ customField,
+ stringConstant,
+ textAreaOptional,
+ textField,
+} from "~/form/fields";
+import { _action, id } from "~/utils/zod";
+import { analyzeTrophyModel } from "./core/model-analysis";
+import {
+ TROPHY_DECLINE_REASON_MAX_LENGTH,
+ TROPHY_DECLINE_REASON_MIN_LENGTH,
+ TROPHY_DESCRIPTION_MAX_LENGTH,
+ TROPHY_MODEL_MAX_LENGTH,
+ TROPHY_NAME_MAX_LENGTH,
+ TROPHY_NAME_MIN_LENGTH,
+} from "./trophies-constants";
+
+const trophyModelField = () =>
+ customField(
+ { initialValue: "" },
+ z
+ .string()
+ .trim()
+ .min(1)
+ .max(TROPHY_MODEL_MAX_LENGTH)
+ .superRefine((model, ctx) => {
+ const analysis = analyzeTrophyModel(model);
+
+ if (!analysis) {
+ ctx.addIssue({ code: "custom", message: "Invalid model state" });
+ return;
+ }
+
+ if (!analysis.cameraTargetCentered) {
+ ctx.addIssue({
+ code: "custom",
+ message: "Camera target X and Z must be 0",
+ });
+ }
+
+ if (!analysis.backgroundIsAlpha) {
+ ctx.addIssue({
+ code: "custom",
+ message: "Background color must be the alpha color",
+ });
+ }
+ }),
+ );
+
+export const createTrophyFormSchema = z.object({
+ _action: stringConstant("CREATE"),
+ name: textField({
+ label: "labels.trophyName",
+ minLength: TROPHY_NAME_MIN_LENGTH,
+ maxLength: TROPHY_NAME_MAX_LENGTH,
+ }),
+ model: trophyModelField(),
+ organizationId: customField({ initialValue: null }, id),
+ description: textAreaOptional({
+ label: "labels.trophyInformation",
+ maxLength: TROPHY_DESCRIPTION_MAX_LENGTH,
+ }),
+});
+
+export const updateTrophyFormSchema = z.object({
+ _action: stringConstant("UPDATE"),
+ targetTrophyId: customField({ initialValue: null }, id),
+ name: textField({
+ label: "labels.trophyName",
+ minLength: TROPHY_NAME_MIN_LENGTH,
+ maxLength: TROPHY_NAME_MAX_LENGTH,
+ }),
+ model: trophyModelField(),
+ organizationId: customField({ initialValue: null }, id),
+ managerId: customField({ initialValue: null }, id),
+ description: textAreaOptional({
+ label: "labels.trophyInformation",
+ maxLength: TROPHY_DESCRIPTION_MAX_LENGTH,
+ }),
+});
+
+export const trophyFormSchema = z.discriminatedUnion("_action", [
+ createTrophyFormSchema,
+ updateTrophyFormSchema,
+]);
+
+export const pendingTrophyActionSchema = z.union([
+ z.object({
+ _action: _action("DELETE"),
+ pendingTrophyId: id,
+ }),
+ z.object({
+ _action: _action("DECLINE"),
+ pendingTrophyId: id,
+ reason: z
+ .string()
+ .trim()
+ .min(TROPHY_DECLINE_REASON_MIN_LENGTH)
+ .max(TROPHY_DECLINE_REASON_MAX_LENGTH),
+ }),
+ z.object({
+ _action: _action("APPROVE"),
+ pendingTrophyId: id,
+ }),
+]);
diff --git a/app/features/trophies/trophies-utils.test.ts b/app/features/trophies/trophies-utils.test.ts
new file mode 100644
index 000000000..1ed021723
--- /dev/null
+++ b/app/features/trophies/trophies-utils.test.ts
@@ -0,0 +1,123 @@
+import { addWeeks, subDays } from "date-fns";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { decompressFromBase64 } from "~/utils/compression";
+import { dateToDatabaseTimestamp } from "~/utils/dates";
+import {
+ canAccessTrophies,
+ decompressTrophyModel,
+ hasUpcomingTournamentSoon,
+} from "./trophies-utils";
+
+vi.mock("~/utils/compression", () => ({
+ compressToBase64: vi.fn((value: string) => value),
+ decompressFromBase64: vi.fn((_compressed: string): string | null => null),
+}));
+
+const decompressMock = vi.mocked(decompressFromBase64);
+
+const ENTRY_CHARS = 3_500_000;
+
+describe("decompressTrophyModel", () => {
+ beforeEach(() => {
+ decompressMock.mockReset();
+ });
+
+ test("decompresses each distinct model once", () => {
+ flushCache("once");
+ decompressMock.mockImplementation((key) => `decompressed-${key}`);
+
+ expect(decompressTrophyModel("once-model")).toBe("decompressed-once-model");
+ expect(decompressTrophyModel("once-model")).toBe("decompressed-once-model");
+ expect(callsFor("once-model")).toBe(1);
+ });
+
+ test("caches null results of corrupt models", () => {
+ flushCache("corrupt");
+ decompressMock.mockImplementation(() => null);
+
+ expect(decompressTrophyModel("corrupt-model")).toBe(null);
+ expect(decompressTrophyModel("corrupt-model")).toBe(null);
+ expect(callsFor("corrupt-model")).toBe(1);
+ });
+
+ test("evicts the least recently used entry when over the character budget", () => {
+ flushCache("lru");
+ decompressMock.mockImplementation(() => "x".repeat(ENTRY_CHARS));
+
+ decompressTrophyModel("lru-a");
+ decompressTrophyModel("lru-b");
+ decompressTrophyModel("lru-c");
+ decompressTrophyModel("lru-d");
+
+ decompressTrophyModel("lru-a");
+ expect(callsFor("lru-a")).toBe(1);
+
+ decompressTrophyModel("lru-e");
+
+ expect(decompressTrophyModel("lru-a")?.length).toBe(ENTRY_CHARS);
+ expect(callsFor("lru-a")).toBe(1);
+
+ decompressTrophyModel("lru-b");
+ expect(callsFor("lru-b")).toBe(2);
+ });
+});
+
+describe("canAccessTrophies (before release)", () => {
+ test("true for admin", () => {
+ expect(canAccessTrophies({ roles: ["ADMIN"] })).toBe(true);
+ });
+
+ test("true for QA", () => {
+ expect(canAccessTrophies({ roles: ["QA"] })).toBe(true);
+ });
+
+ test("false for staff", () => {
+ expect(canAccessTrophies({ roles: ["STAFF"] })).toBe(false);
+ });
+
+ test("false for regular and logged out users", () => {
+ expect(canAccessTrophies({ roles: [] })).toBe(false);
+ expect(canAccessTrophies(null)).toBe(false);
+ });
+});
+
+describe("hasUpcomingTournamentSoon", () => {
+ test("false for a trophy without an upcoming tournament", () => {
+ expect(hasUpcomingTournamentSoon(null)).toBe(false);
+ });
+
+ test("true for a start time within the window", () => {
+ expect(
+ hasUpcomingTournamentSoon(
+ dateToDatabaseTimestamp(addWeeks(new Date(), 2)),
+ ),
+ ).toBe(true);
+ });
+
+ test("false for a start time in the past", () => {
+ expect(
+ hasUpcomingTournamentSoon(
+ dateToDatabaseTimestamp(subDays(new Date(), 1)),
+ ),
+ ).toBe(false);
+ });
+
+ test("false for a start time beyond the window", () => {
+ expect(
+ hasUpcomingTournamentSoon(
+ dateToDatabaseTimestamp(addWeeks(new Date(), 5)),
+ ),
+ ).toBe(false);
+ });
+});
+
+function callsFor(key: string) {
+ return decompressMock.mock.calls.filter(([arg]) => arg === key).length;
+}
+
+function flushCache(prefix: string) {
+ decompressMock.mockImplementation(() => "x".repeat(17 * 1024 * 1024));
+ decompressTrophyModel(`${prefix}-flush-filler`);
+ decompressMock.mockImplementation(() => "");
+ decompressTrophyModel(`${prefix}-flush-remainder`);
+}
diff --git a/app/features/trophies/trophies-utils.ts b/app/features/trophies/trophies-utils.ts
new file mode 100644
index 000000000..083b537c9
--- /dev/null
+++ b/app/features/trophies/trophies-utils.ts
@@ -0,0 +1,166 @@
+import { addWeeks } from "date-fns";
+import { useEffect, useRef, useState, useSyncExternalStore } from "react";
+import type { Role } from "~/modules/permissions/types";
+import { compressToBase64, decompressFromBase64 } from "~/utils/compression";
+import { databaseTimestampToDate } from "~/utils/dates";
+import {
+ SUPPORTER_TROPHY_CODE,
+ TROPHIES_RELEASED,
+ TROPHY_UPCOMING_HIGHLIGHT_WEEKS,
+ XP_TROPHY_CODE_PREFIX,
+} from "./trophies-constants";
+
+const TERMS_AGREED_SESSION_STORAGE_KEY = "trophyTermsAgreed";
+
+const DECOMPRESSED_MODEL_CACHE_MAX_CHARS = 16 * 1024 * 1024;
+
+type SpecialTrophyKind = { type: "supporter" } | { type: "xp"; value: number };
+
+export function parseSpecialTrophyCode(
+ code: string | null | undefined,
+): SpecialTrophyKind | null {
+ if (!code) return null;
+ if (code === SUPPORTER_TROPHY_CODE) return { type: "supporter" };
+
+ if (code.startsWith(XP_TROPHY_CODE_PREFIX)) {
+ const value = Number(code.slice(XP_TROPHY_CODE_PREFIX.length));
+ if (Number.isFinite(value)) return { type: "xp", value };
+ }
+
+ return null;
+}
+
+// Feature flag
+export function canAccessTrophies(user?: { roles: Array } | null) {
+ if (TROPHIES_RELEASED) return true;
+ if (!user) return false;
+
+ return user.roles.includes("ADMIN") || user.roles.includes("QA");
+}
+
+export function canReviewTrophies(user?: { roles: Array } | null) {
+ if (!user) return false;
+
+ return user.roles.includes("STAFF") || user.roles.includes("QA");
+}
+
+export function canEditAnyTrophy(user?: { roles: Array } | null) {
+ if (!user) return false;
+
+ return user.roles.includes("ADMIN");
+}
+
+export function canEditTrophy(
+ user: { id: number; roles: Array } | null | undefined,
+ trophy: { managerId: number | null },
+) {
+ if (!user) return false;
+ if (canEditAnyTrophy(user)) return true;
+ return trophy.managerId === user.id;
+}
+
+export function hasUpcomingTournamentSoon(
+ upcomingTournamentAt: number | null | undefined,
+) {
+ if (!upcomingTournamentAt) return false;
+
+ const startTime = databaseTimestampToDate(upcomingTournamentAt);
+ const now = new Date();
+
+ return (
+ startTime > now &&
+ startTime <= addWeeks(now, TROPHY_UPCOMING_HIGHLIGHT_WEEKS)
+ );
+}
+
+export function compressTrophyModel(model: string) {
+ return compressToBase64(model);
+}
+
+const decompressedModelCache = new Map();
+let decompressedModelCacheChars = 0;
+
+export function decompressTrophyModel(modelBase64: string) {
+ const cached = decompressedModelCache.get(modelBase64);
+ if (cached !== undefined) {
+ decompressedModelCache.delete(modelBase64);
+ decompressedModelCache.set(modelBase64, cached);
+ return cached;
+ }
+
+ const decompressed = decompressFromBase64(modelBase64);
+ decompressedModelCache.set(modelBase64, decompressed);
+ decompressedModelCacheChars += cacheEntryChars(modelBase64, decompressed);
+
+ for (const [oldestKey, oldestValue] of decompressedModelCache) {
+ if (
+ decompressedModelCacheChars <= DECOMPRESSED_MODEL_CACHE_MAX_CHARS ||
+ decompressedModelCache.size === 1
+ ) {
+ break;
+ }
+
+ decompressedModelCache.delete(oldestKey);
+ decompressedModelCacheChars -= cacheEntryChars(oldestKey, oldestValue);
+ }
+
+ return decompressed;
+}
+
+export function useTrophyTermsAgreement() {
+ const hasAgreedToTerms = useSyncExternalStore(
+ subscribeToTermsAgreed,
+ getTermsAgreedSnapshot,
+ getTermsAgreedServerSnapshot,
+ );
+
+ const agreeToTerms = () => {
+ sessionStorage.setItem(TERMS_AGREED_SESSION_STORAGE_KEY, "true");
+ for (const listener of termsAgreedListeners) {
+ listener();
+ }
+ };
+
+ return { hasAgreedToTerms, agreeToTerms };
+}
+
+const termsAgreedListeners = new Set<() => void>();
+
+function subscribeToTermsAgreed(listener: () => void) {
+ termsAgreedListeners.add(listener);
+ return () => termsAgreedListeners.delete(listener);
+}
+
+function getTermsAgreedSnapshot() {
+ return sessionStorage.getItem(TERMS_AGREED_SESSION_STORAGE_KEY) === "true";
+}
+
+function getTermsAgreedServerSnapshot() {
+ return false;
+}
+
+function cacheEntryChars(key: string, value: string | null) {
+ return key.length + (value?.length ?? 0);
+}
+
+export function useProgressiveRender(total: number, resetKey: string) {
+ const [count, setCount] = useState(1);
+ const prevKeyRef = useRef(resetKey);
+
+ if (prevKeyRef.current !== resetKey) {
+ prevKeyRef.current = resetKey;
+ setCount(1);
+ }
+
+ useEffect(() => {
+ if (count >= total) return;
+
+ const id = requestAnimationFrame(() => {
+ setCount((c) => c + 1);
+ });
+
+ return () => cancelAnimationFrame(id);
+ }, [count, total]);
+
+ return count;
+}
diff --git a/app/features/user-page/UserRepository.server.ts b/app/features/user-page/UserRepository.server.ts
index 427950348..40fb190a0 100644
--- a/app/features/user-page/UserRepository.server.ts
+++ b/app/features/user-page/UserRepository.server.ts
@@ -156,6 +156,8 @@ export async function findProfileByIdentifier(
"User.showDiscordUniqueName",
"User.discordUniqueName",
"User.favoriteBadgeIds",
+ "User.favoriteTrophyIds",
+ "User.hiddenTrophyIds",
"User.patronTier",
"PlusTier.tier as plusTier",
"User.pronouns",
@@ -469,6 +471,18 @@ export function findAllPlusServerMembers() {
.execute();
}
+export async function existingUserIds(userIds: Array) {
+ if (userIds.length === 0) return [];
+
+ const rows = await db
+ .selectFrom("User")
+ .select("User.id")
+ .where("User.id", "in", userIds)
+ .execute();
+
+ return rows.map((row) => row.id);
+}
+
export async function findChatUsersByUserIds(userIds: number[]) {
const users = await db
.selectFrom("User")
@@ -1050,6 +1064,8 @@ type UpdateProfileArgs = Pick<
> & {
weapons: Pick[];
favoriteBadgeIds?: number[] | null;
+ favoriteTrophyIds?: number[] | null;
+ hiddenTrophyIds?: number[] | null;
customAvatarImgId?: number | null;
};
export function updateOwnProfile(args: UpdateProfileArgs) {
@@ -1104,6 +1120,12 @@ export function updateOwnProfile(args: UpdateProfileArgs) {
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,
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 16a963150..0848cb352 100644
--- a/app/features/user-page/actions/u.$identifier.edit.server.ts
+++ b/app/features/user-page/actions/u.$identifier.edit.server.ts
@@ -3,6 +3,7 @@ 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";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { parseFormDataWithImages } from "~/form/parse.server";
import { userPage } from "~/utils/urls";
@@ -54,6 +55,13 @@ export const action: ActionFunction = async ({ request }) => {
: 1;
const limitedBadgeIds = data.favoriteBadgeIds.slice(0, maxBadgeCount);
+ const hiddenTrophySet = new Set(data.hiddenTrophyIds);
+ const limitedTrophyIds = isSupporter
+ ? data.favoriteTrophyIds
+ .filter((id) => !hiddenTrophySet.has(id))
+ .slice(0, SMALL_TROPHIES_PER_DISPLAY_PAGE)
+ : [];
+
const editedUser = await UserRepository.updateOwnProfile({
country: data.country,
bio: data.bio,
@@ -66,6 +74,9 @@ export const action: ActionFunction = async ({ request }) => {
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,
diff --git a/app/features/user-page/components/ParticipationPill.module.css b/app/features/user-page/components/ParticipationPill.module.css
index dfdb8fbb1..9b1f2443b 100644
--- a/app/features/user-page/components/ParticipationPill.module.css
+++ b/app/features/user-page/components/ParticipationPill.module.css
@@ -25,5 +25,5 @@
}
.participating {
- background-color: var(--color-accent);
+ background-color: var(--color-text-accent);
}
diff --git a/app/features/user-page/components/Widget.tsx b/app/features/user-page/components/Widget.tsx
index d27c50ac7..b98280257 100644
--- a/app/features/user-page/components/Widget.tsx
+++ b/app/features/user-page/components/Widget.tsx
@@ -18,6 +18,7 @@ import { Placement } from "~/components/Placement";
import type { Tables } from "~/db/tables";
import { previewUrl } from "~/features/art/art-utils";
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
+import { TrophyDisplay } from "~/features/trophies/components/TrophyDisplay";
import { VodListing } from "~/features/vods/components/VodListing";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useAutoRerender } from "~/hooks/useAutoRerender";
@@ -78,6 +79,8 @@ export function Widget({
{widget.data.bio}
);
+ case "trophies-owned":
+ return ;
case "badges-owned":
return (
diff --git a/app/features/user-page/core/trophy-sorting.server.ts b/app/features/user-page/core/trophy-sorting.server.ts
new file mode 100644
index 000000000..cf1624202
--- /dev/null
+++ b/app/features/user-page/core/trophy-sorting.server.ts
@@ -0,0 +1,59 @@
+import { isSupporter } from "~/modules/permissions/utils";
+
+interface SortTrophiesByFavoritesArgs<
+ T extends Array<{ id: number; tier?: number | null }>,
+> {
+ favoriteTrophyIds: number[] | null;
+ hiddenTrophyIds: number[] | null;
+ trophies: T;
+ patronTier: number | null;
+}
+
+export function sortTrophiesByFavorites<
+ T extends Array<{ id: number; tier?: number | null }>,
+>({
+ favoriteTrophyIds,
+ hiddenTrophyIds,
+ trophies,
+ patronTier,
+}: SortTrophiesByFavoritesArgs): {
+ trophies: T;
+ favoriteTrophyIds: number[] | null;
+} {
+ const hiddenSet = new Set(hiddenTrophyIds ?? []);
+
+ let filteredFavoriteIds =
+ favoriteTrophyIds?.filter(
+ (trophyId) =>
+ !hiddenSet.has(trophyId) &&
+ trophies.some((trophy) => trophy.id === trophyId),
+ ) ?? null;
+
+ if (filteredFavoriteIds?.length === 0) {
+ filteredFavoriteIds = null;
+ }
+
+ filteredFavoriteIds = isSupporter({ patronTier })
+ ? filteredFavoriteIds
+ : null;
+
+ const sortedTrophies = trophies.toSorted((a, b) => {
+ const aIdx = filteredFavoriteIds?.indexOf(a.id) ?? -1;
+ const bIdx = filteredFavoriteIds?.indexOf(b.id) ?? -1;
+
+ if (aIdx !== bIdx) {
+ if (aIdx === -1) return 1;
+ if (bIdx === -1) return -1;
+
+ return aIdx - bIdx;
+ }
+
+ const aTier = a.tier ?? Number.MAX_SAFE_INTEGER;
+ const bTier = b.tier ?? Number.MAX_SAFE_INTEGER;
+ if (aTier !== bTier) return aTier - bTier;
+
+ return b.id - a.id;
+ }) as T;
+
+ return { trophies: sortedTrophies, favoriteTrophyIds: 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 b42a13a4f..24e005e4e 100644
--- a/app/features/user-page/core/widgets/portfolio-loaders.server.ts
+++ b/app/features/user-page/core/widgets/portfolio-loaders.server.ts
@@ -1,4 +1,5 @@
import * as ArtRepository from "~/features/art/ArtRepository.server";
+import { getUser } from "~/features/auth/core/user.server";
import * as BadgeRepository from "~/features/badges/BadgeRepository.server";
import * as BuildRepository from "~/features/builds/BuildRepository.server";
import * as FriendRepository from "~/features/friends/FriendRepository.server";
@@ -9,6 +10,8 @@ import { userSkills as _userSkills } from "~/features/mmr/tiered.server";
import * as TeamRepository from "~/features/team/TeamRepository.server";
import * as XRankPlacementRepository from "~/features/top-search/XRankPlacementRepository.server";
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
+import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
+import { canAccessTrophies } from "~/features/trophies/trophies-utils";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import * as VodRepository from "~/features/vods/VodRepository.server";
import { weaponCategories } from "~/modules/in-game-lists/weapon-ids";
@@ -16,6 +19,11 @@ import type { ExtractWidgetSettings } from "./types";
import { cachedUserSQLeaderboardTopData } from "./utils.server";
export const WIDGET_LOADERS = {
+ "trophies-owned": async (userId: number) => {
+ if (!canAccessTrophies(getUser())) return [];
+
+ return TrophyRepository.findByOwnerUserId(userId);
+ },
"badges-owned": async (userId: number) => {
return BadgeRepository.findByOwnerUserId(userId);
},
diff --git a/app/features/user-page/core/widgets/portfolio.ts b/app/features/user-page/core/widgets/portfolio.ts
index aeb658cd8..c42c825b2 100644
--- a/app/features/user-page/core/widgets/portfolio.ts
+++ b/app/features/user-page/core/widgets/portfolio.ts
@@ -73,6 +73,7 @@ export const ALL_WIDGETS = {
defaultSettings: { searchParams: "" },
}),
],
+ trophies: [defineWidget({ id: "trophies-owned", slot: "main" })],
badges: [
defineWidget({ id: "badges-owned", slot: "main" }),
defineWidget({ id: "badges-authored", slot: "main" }),
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 3b891078a..bc61ff13a 100644
--- a/app/features/user-page/loaders/u.$identifier.edit.server.ts
+++ b/app/features/user-page/loaders/u.$identifier.edit.server.ts
@@ -1,5 +1,7 @@
import { type LoaderFunctionArgs, redirect } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
+import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
+import { canAccessTrophies } from "~/features/trophies/trophies-utils";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { notFoundIfNullish } from "~/utils/remix.server";
import { userPage } from "~/utils/urls";
@@ -23,10 +25,16 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
const friendCodeResult = await UserRepository.findCurrentFriendCodeByUserId(
user.id,
);
+ const ownedTrophies = canAccessTrophies(user)
+ ? await TrophyRepository.findByOwnerUserIdIncludingHidden(user.id)
+ : [];
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 05c445abc..7972af40a 100644
--- a/app/features/user-page/loaders/u.$identifier.index.server.ts
+++ b/app/features/user-page/loaders/u.$identifier.index.server.ts
@@ -1,4 +1,7 @@
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";
@@ -30,9 +33,14 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
await UserRepository.findProfileByIdentifier(params.identifier!),
);
+ const trophies = canAccessTrophies(getUser())
+ ? await TrophyRepository.findByOwnerUserId(user.id)
+ : [];
+
return {
type: "old" as const,
user,
+ trophies,
...userCards,
};
};
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 c3fc0d3ad..0c2ba3328 100644
--- a/app/features/user-page/routes/u.$identifier.edit.test.ts
+++ b/app/features/user-page/routes/u.$identifier.edit.test.ts
@@ -19,6 +19,8 @@ const DEFAULT_FIELDS = {
customName: null,
customUrl: null,
favoriteBadgeIds: [],
+ favoriteTrophyIds: [],
+ hiddenTrophyIds: [],
inGameName: null,
sensitivity: [null, null] as [null, null],
pronouns: [null, null] as [null, null],
diff --git a/app/features/user-page/routes/u.$identifier.edit.tsx b/app/features/user-page/routes/u.$identifier.edit.tsx
index 66d5c8c07..802f68837 100644
--- a/app/features/user-page/routes/u.$identifier.edit.tsx
+++ b/app/features/user-page/routes/u.$identifier.edit.tsx
@@ -3,6 +3,7 @@ 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";
import { useHydrated } from "~/hooks/useHydrated";
@@ -41,6 +42,13 @@ export default function UserEditPage() {
hue: badge.hue,
}));
+ const trophyOptions = data.ownedTrophies.map((trophy) => ({
+ id: trophy.id,
+ name: trophy.name,
+ model: trophy.model,
+ tier: trophy.tier,
+ }));
+
const defaultValues = {
customAvatar: existingImage(
data.user.customAvatarImgId,
@@ -54,6 +62,8 @@ export default function UserEditPage() {
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),
@@ -93,6 +103,16 @@ export default function UserEditPage() {
}
/>
) : null}
+ {isSupporter && data.ownedTrophies.length >= 2 ? (
+
+ ) : null}
+ {data.ownedTrophies.length >= 1 ? (
+
+ ) : null}
{data.discordUniqueName ? (
diff --git a/app/features/user-page/routes/u.$identifier.index.tsx b/app/features/user-page/routes/u.$identifier.index.tsx
index e5150f9c3..05152cc4d 100644
--- a/app/features/user-page/routes/u.$identifier.index.tsx
+++ b/app/features/user-page/routes/u.$identifier.index.tsx
@@ -20,6 +20,7 @@ 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 { 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";
@@ -56,6 +57,7 @@ export const handle: SendouRouteHandle = {
"weapons",
"gear",
"game-badges",
+ "trophies",
],
};
@@ -231,7 +233,17 @@ export function OldUserInfoPage() {
-
+ {data.trophies.length > 0 ? (
+
+ ) : null}
+
{data.user.bio && {data.user.bio} }
);
diff --git a/app/features/user-page/user-page-schemas.ts b/app/features/user-page/user-page-schemas.ts
index 89a190d0a..7acad05fd 100644
--- a/app/features/user-page/user-page-schemas.ts
+++ b/app/features/user-page/user-page-schemas.ts
@@ -1,6 +1,7 @@
import { z } from "zod";
import { BADGE } from "~/features/badges/badges-constants";
import * as Seasons from "~/features/mmr/core/Seasons";
+import { SMALL_TROPHIES_PER_DISPLAY_PAGE } from "~/features/trophies/trophies-constants";
import {
OBJECT_PRONOUNS,
SUBJECT_PRONOUNS,
@@ -20,6 +21,7 @@ import {
textField,
textFieldOptional,
toggle,
+ trophies,
weaponPool,
} from "~/form/fields";
import {
@@ -142,6 +144,13 @@ export const userEditProfileBaseSchema = z.object({
label: "labels.profileFavoriteBadges",
maxCount: BADGE.SMALL_BADGES_PER_DISPLAY_PAGE + 1,
}),
+ favoriteTrophyIds: trophies({
+ label: "labels.profileFavoriteTrophies",
+ maxCount: SMALL_TROPHIES_PER_DISPLAY_PAGE,
+ }),
+ hiddenTrophyIds: trophies({
+ label: "labels.profileHiddenTrophies",
+ }),
weapons: weaponPool({
label: "labels.weaponPool",
maxCount: USER.WEAPON_POOL_MAX_SIZE,
diff --git a/app/form/FormField.tsx b/app/form/FormField.tsx
index 16c54cbef..6f00c37ce 100644
--- a/app/form/FormField.tsx
+++ b/app/form/FormField.tsx
@@ -21,6 +21,7 @@ import { TeamSearchFormField } from "./fields/TeamSearchFormField";
import { TextareaFormField } from "./fields/TextareaFormField";
import { TimeRangeFormField } from "./fields/TimeRangeFormField";
import { TournamentSearchFormField } from "./fields/TournamentSearchFormField";
+import { TrophiesFormField } from "./fields/TrophiesFormField";
import { UserSearchFormField } from "./fields/UserSearchFormField";
import {
WeaponPoolFormField,
@@ -38,6 +39,7 @@ import type {
SelectOption,
TeamSearchFieldOptions,
TournamentSearchFieldOptions,
+ TrophyOption,
UserSearchFieldOptions,
} from "./types";
import {
@@ -557,6 +559,22 @@ export function FormField({
);
}
+ if (formField.type === "trophies") {
+ if (!options) {
+ throw new Error("Trophies form field requires options prop");
+ }
+ return (
+ void}
+ options={options as TrophyOption[]}
+ {...(maxCount !== undefined ? { maxCount } : {})}
+ />
+ );
+ }
+
if (formField.type === "stage-select") {
return (
();
@@ -838,6 +839,23 @@ export function badges(
}) as z.ZodArray & FieldWithOptions;
}
+export function trophies(
+ args: WithTypedTranslationKeys<
+ Omit, "type" | "initialValue">
+ >,
+) {
+ return z
+ .array(id)
+ .max(args.maxCount ?? 100)
+ .register(formRegistry, {
+ ...args,
+ label: prefixKey(args.label),
+ bottomText: prefixKey(args.bottomText),
+ type: "trophies",
+ initialValue: [],
+ }) as z.ZodArray & FieldWithOptions;
+}
+
export function stageSelect(
args: WithTypedTranslationKeys<
Omit<
diff --git a/app/form/fields/TrophiesFormField.tsx b/app/form/fields/TrophiesFormField.tsx
new file mode 100644
index 000000000..dda2cf5e8
--- /dev/null
+++ b/app/form/fields/TrophiesFormField.tsx
@@ -0,0 +1,43 @@
+import * as React from "react";
+import { TrophiesSelector } from "~/features/trophies/components/TrophiesSelector";
+import type { FormFieldProps, TrophyOption } from "../types";
+import { FormFieldWrapper } from "./FormFieldWrapper";
+
+type TrophiesFormFieldProps = Omit, "onBlur"> & {
+ value: number[];
+ onChange: (value: number[]) => void;
+ onBlur?: () => void;
+ options: TrophyOption[];
+};
+
+export function TrophiesFormField({
+ name,
+ label,
+ bottomText,
+ error,
+ maxCount,
+ value,
+ onChange,
+ onBlur,
+ options,
+}: TrophiesFormFieldProps) {
+ const id = React.useId();
+
+ return (
+
+
+
+ );
+}
diff --git a/app/form/types.ts b/app/form/types.ts
index ca2f1296d..2f7d2acd7 100644
--- a/app/form/types.ts
+++ b/app/form/types.ts
@@ -163,6 +163,10 @@ interface FormFieldBadges extends FormFieldBase {
maxCount?: number;
}
+interface FormFieldTrophies extends FormFieldBase {
+ maxCount?: number;
+}
+
interface FormFieldSelectDynamic extends FormFieldBase {
clearable: boolean;
searchable?: boolean;
@@ -205,6 +209,7 @@ export type FormField =
| FormFieldTournamentSearch<"tournament-search">
| FormFieldTeamSearch<"team-search">
| FormFieldBadges<"badges">
+ | FormFieldTrophies<"trophies">
| FormFieldStageSelect<"stage-select">
| FormFieldWeaponSelect<"weapon-select">;
@@ -237,6 +242,13 @@ export type BadgeOption = {
hue: number | null;
};
+export type TrophyOption = {
+ id: number;
+ name: string;
+ model: string;
+ tier: number | null;
+};
+
export type SelectOption = {
value: string;
label: string;
diff --git a/app/modules/i18n/resources.server.ts b/app/modules/i18n/resources.server.ts
index f85081659..faf9546f5 100644
--- a/app/modules/i18n/resources.server.ts
+++ b/app/modules/i18n/resources.server.ts
@@ -21,6 +21,7 @@ import settingsDa from "../../../locales/da/settings.json";
import teamDa from "../../../locales/da/team.json";
import tierListMakerDa from "../../../locales/da/tier-list-maker.json";
import tournamentDa from "../../../locales/da/tournament.json";
+import trophiesDa from "../../../locales/da/trophies.json";
import userDa from "../../../locales/da/user.json";
import vodsDa from "../../../locales/da/vods.json";
import weaponsDa from "../../../locales/da/weapons.json";
@@ -48,6 +49,7 @@ import settingsDe from "../../../locales/de/settings.json";
import teamDe from "../../../locales/de/team.json";
import tierListMakerDe from "../../../locales/de/tier-list-maker.json";
import tournamentDe from "../../../locales/de/tournament.json";
+import trophiesDe from "../../../locales/de/trophies.json";
import userDe from "../../../locales/de/user.json";
import vodsDe from "../../../locales/de/vods.json";
import weaponsDe from "../../../locales/de/weapons.json";
@@ -75,6 +77,7 @@ import settings from "../../../locales/en/settings.json";
import team from "../../../locales/en/team.json";
import tierListMaker from "../../../locales/en/tier-list-maker.json";
import tournament from "../../../locales/en/tournament.json";
+import trophies from "../../../locales/en/trophies.json";
import user from "../../../locales/en/user.json";
import vods from "../../../locales/en/vods.json";
import weapons from "../../../locales/en/weapons.json";
@@ -102,6 +105,7 @@ import settingsEsEs from "../../../locales/es-ES/settings.json";
import teamEsEs from "../../../locales/es-ES/team.json";
import tierListMakerEsEs from "../../../locales/es-ES/tier-list-maker.json";
import tournamentEsEs from "../../../locales/es-ES/tournament.json";
+import trophiesEsEs from "../../../locales/es-ES/trophies.json";
import userEsEs from "../../../locales/es-ES/user.json";
import vodsEsEs from "../../../locales/es-ES/vods.json";
import weaponsEsEs from "../../../locales/es-ES/weapons.json";
@@ -129,6 +133,7 @@ import settingsEsUs from "../../../locales/es-US/settings.json";
import teamEsUs from "../../../locales/es-US/team.json";
import tierListMakerEsUs from "../../../locales/es-US/tier-list-maker.json";
import tournamentEsUs from "../../../locales/es-US/tournament.json";
+import trophiesEsUs from "../../../locales/es-US/trophies.json";
import userEsUs from "../../../locales/es-US/user.json";
import vodsEsUs from "../../../locales/es-US/vods.json";
import weaponsEsUs from "../../../locales/es-US/weapons.json";
@@ -156,6 +161,7 @@ import settingsFrCa from "../../../locales/fr-CA/settings.json";
import teamFrCa from "../../../locales/fr-CA/team.json";
import tierListMakerFrCa from "../../../locales/fr-CA/tier-list-maker.json";
import tournamentFrCa from "../../../locales/fr-CA/tournament.json";
+import trophiesFrCa from "../../../locales/fr-CA/trophies.json";
import userFrCa from "../../../locales/fr-CA/user.json";
import vodsFrCa from "../../../locales/fr-CA/vods.json";
import weaponsFrCa from "../../../locales/fr-CA/weapons.json";
@@ -183,6 +189,7 @@ import settingsFrEu from "../../../locales/fr-EU/settings.json";
import teamFrEu from "../../../locales/fr-EU/team.json";
import tierListMakerFrEu from "../../../locales/fr-EU/tier-list-maker.json";
import tournamentFrEu from "../../../locales/fr-EU/tournament.json";
+import trophiesFrEu from "../../../locales/fr-EU/trophies.json";
import userFrEu from "../../../locales/fr-EU/user.json";
import vodsFrEu from "../../../locales/fr-EU/vods.json";
import weaponsFrEu from "../../../locales/fr-EU/weapons.json";
@@ -210,6 +217,7 @@ import settingsHe from "../../../locales/he/settings.json";
import teamHe from "../../../locales/he/team.json";
import tierListMakerHe from "../../../locales/he/tier-list-maker.json";
import tournamentHe from "../../../locales/he/tournament.json";
+import trophiesHe from "../../../locales/he/trophies.json";
import userHe from "../../../locales/he/user.json";
import vodsHe from "../../../locales/he/vods.json";
import weaponsHe from "../../../locales/he/weapons.json";
@@ -237,6 +245,7 @@ import settingsIt from "../../../locales/it/settings.json";
import teamIt from "../../../locales/it/team.json";
import tierListMakerIt from "../../../locales/it/tier-list-maker.json";
import tournamentIt from "../../../locales/it/tournament.json";
+import trophiesIt from "../../../locales/it/trophies.json";
import userIt from "../../../locales/it/user.json";
import vodsIt from "../../../locales/it/vods.json";
import weaponsIt from "../../../locales/it/weapons.json";
@@ -264,6 +273,7 @@ import settingsJa from "../../../locales/ja/settings.json";
import teamJa from "../../../locales/ja/team.json";
import tierListMakerJa from "../../../locales/ja/tier-list-maker.json";
import tournamentJa from "../../../locales/ja/tournament.json";
+import trophiesJa from "../../../locales/ja/trophies.json";
import userJa from "../../../locales/ja/user.json";
import vodsJa from "../../../locales/ja/vods.json";
import weaponsJa from "../../../locales/ja/weapons.json";
@@ -291,6 +301,7 @@ import settingsKo from "../../../locales/ko/settings.json";
import teamKo from "../../../locales/ko/team.json";
import tierListMakerKo from "../../../locales/ko/tier-list-maker.json";
import tournamentKo from "../../../locales/ko/tournament.json";
+import trophiesKo from "../../../locales/ko/trophies.json";
import userKo from "../../../locales/ko/user.json";
import vodsKo from "../../../locales/ko/vods.json";
import weaponsKo from "../../../locales/ko/weapons.json";
@@ -318,6 +329,7 @@ import settingsNl from "../../../locales/nl/settings.json";
import teamNl from "../../../locales/nl/team.json";
import tierListMakerNl from "../../../locales/nl/tier-list-maker.json";
import tournamentNl from "../../../locales/nl/tournament.json";
+import trophiesNl from "../../../locales/nl/trophies.json";
import userNl from "../../../locales/nl/user.json";
import vodsNl from "../../../locales/nl/vods.json";
import weaponsNl from "../../../locales/nl/weapons.json";
@@ -345,6 +357,7 @@ import settingsPl from "../../../locales/pl/settings.json";
import teamPl from "../../../locales/pl/team.json";
import tierListMakerPl from "../../../locales/pl/tier-list-maker.json";
import tournamentPl from "../../../locales/pl/tournament.json";
+import trophiesPl from "../../../locales/pl/trophies.json";
import userPl from "../../../locales/pl/user.json";
import vodsPl from "../../../locales/pl/vods.json";
import weaponsPl from "../../../locales/pl/weapons.json";
@@ -372,6 +385,7 @@ import settingsPtBr from "../../../locales/pt-BR/settings.json";
import teamPtBr from "../../../locales/pt-BR/team.json";
import tierListMakerPtBr from "../../../locales/pt-BR/tier-list-maker.json";
import tournamentPtBr from "../../../locales/pt-BR/tournament.json";
+import trophiesPtBr from "../../../locales/pt-BR/trophies.json";
import userPtBr from "../../../locales/pt-BR/user.json";
import vodsPtBr from "../../../locales/pt-BR/vods.json";
import weaponsPtBr from "../../../locales/pt-BR/weapons.json";
@@ -399,6 +413,7 @@ import settingsRu from "../../../locales/ru/settings.json";
import teamRu from "../../../locales/ru/team.json";
import tierListMakerRu from "../../../locales/ru/tier-list-maker.json";
import tournamentRu from "../../../locales/ru/tournament.json";
+import trophiesRu from "../../../locales/ru/trophies.json";
import userRu from "../../../locales/ru/user.json";
import vodsRu from "../../../locales/ru/vods.json";
import weaponsRu from "../../../locales/ru/weapons.json";
@@ -426,6 +441,7 @@ import settingsZh from "../../../locales/zh/settings.json";
import teamZh from "../../../locales/zh/team.json";
import tierListMakerZh from "../../../locales/zh/tier-list-maker.json";
import tournamentZh from "../../../locales/zh/tournament.json";
+import trophiesZh from "../../../locales/zh/trophies.json";
import userZh from "../../../locales/zh/user.json";
import vodsZh from "../../../locales/zh/vods.json";
import weaponsZh from "../../../locales/zh/weapons.json";
@@ -457,6 +473,7 @@ export const resources = {
badges: badgesEsUs,
contributions: contributionsEsUs,
team: teamEsUs,
+ trophies: trophiesEsUs,
"tier-list-maker": tierListMakerEsUs,
analyzer: analyzerEsUs,
welcome: welcomeEsUs,
@@ -486,6 +503,7 @@ export const resources = {
badges: badges,
contributions: contributions,
team: team,
+ trophies: trophies,
"tier-list-maker": tierListMaker,
analyzer: analyzer,
welcome: welcomeEn,
@@ -515,6 +533,7 @@ export const resources = {
badges: badgesKo,
contributions: contributionsKo,
team: teamKo,
+ trophies: trophiesKo,
"tier-list-maker": tierListMakerKo,
analyzer: analyzerKo,
welcome: welcomeKo,
@@ -544,6 +563,7 @@ export const resources = {
badges: badgesDe,
contributions: contributionsDe,
team: teamDe,
+ trophies: trophiesDe,
"tier-list-maker": tierListMakerDe,
analyzer: analyzerDe,
welcome: welcomeDe,
@@ -573,6 +593,7 @@ export const resources = {
badges: badgesNl,
contributions: contributionsNl,
team: teamNl,
+ trophies: trophiesNl,
"tier-list-maker": tierListMakerNl,
analyzer: analyzerNl,
welcome: welcomeNl,
@@ -602,6 +623,7 @@ export const resources = {
badges: badgesPtBr,
contributions: contributionsPtBr,
team: teamPtBr,
+ trophies: trophiesPtBr,
"tier-list-maker": tierListMakerPtBr,
analyzer: analyzerPtBr,
welcome: welcomePtBr,
@@ -631,6 +653,7 @@ export const resources = {
badges: badgesZh,
contributions: contributionsZh,
team: teamZh,
+ trophies: trophiesZh,
"tier-list-maker": tierListMakerZh,
analyzer: analyzerZh,
welcome: welcomeZh,
@@ -660,6 +683,7 @@ export const resources = {
badges: badgesFrCa,
contributions: contributionsFrCa,
team: teamFrCa,
+ trophies: trophiesFrCa,
"tier-list-maker": tierListMakerFrCa,
analyzer: analyzerFrCa,
welcome: welcomeFrCa,
@@ -689,6 +713,7 @@ export const resources = {
badges: badgesRu,
contributions: contributionsRu,
team: teamRu,
+ trophies: trophiesRu,
"tier-list-maker": tierListMakerRu,
analyzer: analyzerRu,
welcome: welcomeRu,
@@ -718,6 +743,7 @@ export const resources = {
badges: badgesIt,
contributions: contributionsIt,
team: teamIt,
+ trophies: trophiesIt,
"tier-list-maker": tierListMakerIt,
analyzer: analyzerIt,
welcome: welcomeIt,
@@ -747,6 +773,7 @@ export const resources = {
badges: badgesJa,
contributions: contributionsJa,
team: teamJa,
+ trophies: trophiesJa,
"tier-list-maker": tierListMakerJa,
analyzer: analyzerJa,
welcome: welcomeJa,
@@ -776,6 +803,7 @@ export const resources = {
badges: badgesDa,
contributions: contributionsDa,
team: teamDa,
+ trophies: trophiesDa,
"tier-list-maker": tierListMakerDa,
analyzer: analyzerDa,
welcome: welcomeDa,
@@ -805,6 +833,7 @@ export const resources = {
badges: badgesEsEs,
contributions: contributionsEsEs,
team: teamEsEs,
+ trophies: trophiesEsEs,
"tier-list-maker": tierListMakerEsEs,
analyzer: analyzerEsEs,
welcome: welcomeEsEs,
@@ -834,6 +863,7 @@ export const resources = {
badges: badgesHe,
contributions: contributionsHe,
team: teamHe,
+ trophies: trophiesHe,
"tier-list-maker": tierListMakerHe,
analyzer: analyzerHe,
welcome: welcomeHe,
@@ -863,6 +893,7 @@ export const resources = {
badges: badgesFrEu,
contributions: contributionsFrEu,
team: teamFrEu,
+ trophies: trophiesFrEu,
"tier-list-maker": tierListMakerFrEu,
analyzer: analyzerFrEu,
welcome: welcomeFrEu,
@@ -892,6 +923,7 @@ export const resources = {
badges: badgesPl,
contributions: contributionsPl,
team: teamPl,
+ trophies: trophiesPl,
"tier-list-maker": tierListMakerPl,
analyzer: analyzerPl,
welcome: welcomePl,
diff --git a/app/modules/patreon/updater.ts b/app/modules/patreon/updater.ts
index 203f9f6b9..623003b6f 100644
--- a/app/modules/patreon/updater.ts
+++ b/app/modules/patreon/updater.ts
@@ -1,6 +1,7 @@
import type { z } from "zod";
import { ServerConfig } from "~/config.server";
import { STAFF_DISCORD_IDS } from "~/features/admin/admin-constants";
+import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { fetchWithTimeout } from "~/utils/fetch";
@@ -46,6 +47,7 @@ export async function updatePatreonData(): Promise {
];
await UserRepository.updatePatronData(patronsWithMods);
+ await TrophyRepository.syncSpecialTrophies();
}
const MAX_RETRIES = 10;
diff --git a/app/modules/permissions/mapper.server.ts b/app/modules/permissions/mapper.server.ts
index 02c6dbff3..7b47301dc 100644
--- a/app/modules/permissions/mapper.server.ts
+++ b/app/modules/permissions/mapper.server.ts
@@ -1,7 +1,7 @@
import type { UserWithPlusTier } from "~/utils/kysely.server";
import { userDiscordIdIsAged } from "~/utils/users";
import type { Role } from "./types";
-import { isAdmin, isDev, isStaff, isSupporter } from "./utils";
+import { isAdmin, isDev, isQa, isStaff, isSupporter } from "./utils";
export function userRoles(
user: Pick<
@@ -30,6 +30,10 @@ export function userRoles(
result.push("DEV");
}
+ if (isQa(user)) {
+ result.push("QA");
+ }
+
if (typeof user.patronTier === "number") {
result.push("MINOR_SUPPORT");
}
diff --git a/app/modules/permissions/types.ts b/app/modules/permissions/types.ts
index 40f07127f..c4e2aabd7 100644
--- a/app/modules/permissions/types.ts
+++ b/app/modules/permissions/types.ts
@@ -14,6 +14,7 @@ export type Role =
| "CALENDAR_EVENT_ADDER"
| "TOURNAMENT_ADDER"
| "API_ACCESSER"
+ | "QA"
| "DEV"
| "SUPPORTER" // patrons of "Supporter" tier or higher
| "MINOR_SUPPORT"; // patrons of "Support" tier or higher
diff --git a/app/modules/permissions/utils.ts b/app/modules/permissions/utils.ts
index 96b81c84e..3c4b0e859 100644
--- a/app/modules/permissions/utils.ts
+++ b/app/modules/permissions/utils.ts
@@ -1,4 +1,9 @@
-import { ADMIN_ID, DEV_IDS, STAFF_IDS } from "~/features/admin/admin-constants";
+import {
+ ADMIN_ID,
+ DEV_IDS,
+ QA_IDS,
+ STAFF_IDS,
+} from "~/features/admin/admin-constants";
export function isAdmin(user?: { id: number }) {
return user?.id === ADMIN_ID;
@@ -16,6 +21,12 @@ export function isDev(user?: { id: number }) {
return DEV_IDS.includes(user.id);
}
+export function isQa(user?: { id: number }) {
+ if (!user) return false;
+
+ return QA_IDS.includes(user.id);
+}
+
export function isSupporter(user?: { patronTier: number | null }) {
return typeof user?.patronTier === "number" && user.patronTier >= 2;
}
diff --git a/app/routes.ts b/app/routes.ts
index dcd16c0b9..61ea74045 100644
--- a/app/routes.ts
+++ b/app/routes.ts
@@ -110,6 +110,19 @@ export default [
]),
]),
+ route(
+ "/trophies/:id/wins/:userId",
+ "features/trophies/routes/trophies.$id.wins.$userId.ts",
+ ),
+ route(
+ "/trophies/:id/tournaments",
+ "features/trophies/routes/trophies.$id.tournaments.ts",
+ ),
+ route("/trophies", "features/trophies/routes/trophies.tsx", [
+ route(":id", "features/trophies/routes/trophies.$id.tsx"),
+ ]),
+ route("/trophies/new", "features/trophies/routes/trophies.new.tsx"),
+
...prefix("/calendar", [
index("features/calendar/routes/calendar.tsx"),
route("new", "features/calendar/routes/calendar.new.tsx"),
diff --git a/app/styles/vars.css b/app/styles/vars.css
index 9d704dc89..b7f3f298c 100644
--- a/app/styles/vars.css
+++ b/app/styles/vars.css
@@ -80,7 +80,8 @@ cannot use currentColor inside data URLs
Any changes here NEED to be reflected in oklch-gamut.ts as well
*/
html.dark,
-html.dark [data-custom-theme] {
+html.dark [data-custom-theme],
+.dark-preview {
--color-base-0: oklch(100% var(--_base-c-0) var(--_base-h));
--color-base-1: oklch(95% var(--_base-c-1) var(--_base-h));
--color-base-2: oklch(90% var(--_base-c-2) var(--_base-h));
@@ -131,7 +132,8 @@ html.dark [data-custom-theme] {
}
html.light,
-html.light [data-custom-theme] {
+html.light [data-custom-theme],
+.light-preview {
--color-base-0: oklch(17% var(--_base-c-7) var(--_base-h));
--color-base-1: oklch(25% var(--_base-c-6) var(--_base-h));
--color-base-2: oklch(32% var(--_base-c-5) var(--_base-h));
@@ -186,7 +188,9 @@ These are the vars you mainly want to use
*/
html,
/* biome-ignore lint/style/noDescendingSpecificity: [data-custom-theme] re-declares theme vars for card subtrees; different properties than the html.dark/light blocks so no real override */
-[data-custom-theme] {
+[data-custom-theme],
+.dark-preview,
+.light-preview {
--color-text: var(--color-base-0);
--color-text-high: var(--color-base-3);
--color-text-inverse: var(--color-base-7);
diff --git a/app/utils/compression.test.ts b/app/utils/compression.test.ts
new file mode 100644
index 000000000..f72229b9e
--- /dev/null
+++ b/app/utils/compression.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from "vitest";
+import { compressToBase64, decompressFromBase64 } from "./compression";
+
+describe("compressToBase64 & decompressFromBase64", () => {
+ it("round-trips a string", () => {
+ const value = JSON.stringify({ name: "trophy", mesh: [1, 2, 3] });
+
+ expect(decompressFromBase64(compressToBase64(value))).toBe(value);
+ });
+
+ it("round-trips unicode content", () => {
+ const value = "tröphy \u{1f3c6} テスト";
+
+ expect(decompressFromBase64(compressToBase64(value))).toBe(value);
+ });
+
+ it("round-trips with the url safe alphabet", () => {
+ const value = "a".repeat(1000) + JSON.stringify({ b: [4, 5, 6] });
+ const compressed = compressToBase64(value, { urlSafe: true });
+
+ expect(compressed).not.toMatch(/[+/=]/);
+ expect(decompressFromBase64(compressed)).toBe(value);
+ });
+
+ it("returns null for corrupt input", () => {
+ expect(decompressFromBase64("!!!not base64!!!")).toBeNull();
+ expect(decompressFromBase64(btoa("not deflate data"))).toBeNull();
+ expect(decompressFromBase64("")).toBeNull();
+ });
+
+ it("returns null for truncated input", () => {
+ const compressed = compressToBase64("some longer input to compress");
+
+ expect(decompressFromBase64(compressed.slice(0, 4))).toBeNull();
+ });
+});
diff --git a/app/utils/compression.ts b/app/utils/compression.ts
new file mode 100644
index 000000000..332a87e6f
--- /dev/null
+++ b/app/utils/compression.ts
@@ -0,0 +1,42 @@
+import { deflateRaw, inflateRaw } from "pako";
+
+/**
+ * Compresses a string with raw deflate and encodes the result as base64.
+ * With `urlSafe` the output uses the URL-safe base64 alphabet without padding.
+ */
+export function compressToBase64(
+ value: string,
+ options?: { urlSafe?: boolean },
+) {
+ const bytes = deflateRaw(value, { level: 9 });
+ let binary = "";
+
+ for (const byte of bytes) {
+ binary += String.fromCharCode(byte);
+ }
+
+ const base64 = btoa(binary);
+ if (!options?.urlSafe) return base64;
+
+ return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+}
+
+/**
+ * Decompresses a base64 encoded (standard or URL-safe alphabet) raw deflate
+ * string. Returns `null` if the input is corrupt.
+ */
+export function decompressFromBase64(compressed: string) {
+ try {
+ const base64 = compressed.replace(/-/g, "+").replace(/_/g, "/");
+ const value = inflateRaw(
+ Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)),
+ { to: "string" },
+ );
+
+ if (!value) return null;
+
+ return value;
+ } catch {
+ return null;
+ }
+}
diff --git a/app/utils/i18n.ts b/app/utils/i18n.ts
index 680bbfa7e..dcf1e1df7 100644
--- a/app/utils/i18n.ts
+++ b/app/utils/i18n.ts
@@ -30,6 +30,7 @@ const ALL_NAMESPACES = [
"front",
"friends",
"settings",
+ "trophies",
"params",
"welcome",
] as const;
diff --git a/app/utils/kysely.server.ts b/app/utils/kysely.server.ts
index 7fd337381..b013824f5 100644
--- a/app/utils/kysely.server.ts
+++ b/app/utils/kysely.server.ts
@@ -173,6 +173,34 @@ export function tournamentLogoWithDefault(
);
}
+/**
+ * Subquery resolving to the event's earliest `CalendarEventDate` start time, or `null` when it has
+ * no dates. Correlates on `"CalendarEvent"."id"`. Alias it `.as("startTime")` when selecting it
+ * directly. Can also be passed to `orderBy` as is.
+ */
+export function calendarEventStartTime(
+ eb: ExpressionBuilder,
+) {
+ return eb
+ .selectFrom("CalendarEventDate")
+ .select((eb2) => eb2.fn.min("startsAt").as("startsAt"))
+ .whereRef("CalendarEventDate.eventId", "=", "CalendarEvent.id");
+}
+
+/**
+ * Subquery counting a tournament's non-placeholder teams. Correlates on `"Tournament"."id"`.
+ * Alias it `.as("teamsCount")` when selecting it directly.
+ */
+export function tournamentTeamCount(
+ eb: ExpressionBuilder,
+) {
+ return eb
+ .selectFrom("TournamentTeam")
+ .select((eb2) => eb2.fn.countAll().as("count"))
+ .whereRef("TournamentTeam.tournamentId", "=", "Tournament.id")
+ .where("TournamentTeam.isPlaceholder", "=", 0);
+}
+
/** Concats the file name (a bit misleadingly called `url` in the DB schema) with the root URL, giving the full URL for the image */
export function concatUserSubmittedImagePrefix(
expr: Expression,
diff --git a/app/utils/urls.ts b/app/utils/urls.ts
index 51e246eda..ab46ff591 100644
--- a/app/utils/urls.ts
+++ b/app/utils/urls.ts
@@ -68,6 +68,8 @@ export const SPR_INFO_URL =
"https://web.archive.org/web/20250513034545/https://www.pgstats.com/articles/introducing-spr-and-uf";
export const SPLATOON3_INK_SCHEDULES_URL =
"https://splatoon3.ink/data/schedules.json";
+export const PICOCAD2_WEB_VIEWER_URL =
+ "https://picocad2-web-viewer.hfcred.workers.dev/";
export const bskyUrl = (accountName: string) =>
`https://bsky.app/profile/${accountName}`;
@@ -86,6 +88,8 @@ export const WELCOME_PAGE = "/welcome";
export const SUPPORT_PAGE = "/support";
export const CONTRIBUTIONS_PAGE = "/contributions";
export const BADGES_PAGE = "/badges";
+export const TROPHIES_PAGE = "/trophies";
+export const NEW_TROPHY_PAGE = "/trophies/new";
export const BUILDS_PAGE = "/builds";
export const TEAM_SEARCH_PAGE = "/t";
export const NEW_TEAM_PAGE = "/t/new";
@@ -158,6 +162,14 @@ export const userCardNotePage = (userId: number) => `/user-card/${userId}/note`;
export const userReportPage = (userId: number) => `/user-report/${userId}`;
+export const trophyPage = (trophyId: number) => `${TROPHIES_PAGE}/${trophyId}`;
+
+export const trophyWinsPage = (args: { trophyId: number; userId: number }) =>
+ `${TROPHIES_PAGE}/${args.trophyId}/wins/${args.userId}`;
+
+export const trophyTournamentsPage = (trophyId: number) =>
+ `${TROPHIES_PAGE}/${trophyId}/tournaments`;
+
interface UserLinkArgs {
discordId: Tables["User"]["discordId"];
customUrl?: Tables["User"]["customUrl"];
diff --git a/db-test.sqlite3 b/db-test.sqlite3
index a687b38ca..4fb638a25 100644
Binary files a/db-test.sqlite3 and b/db-test.sqlite3 differ
diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts
index cd9799109..350928c2f 100644
--- a/e2e/global-setup.ts
+++ b/e2e/global-setup.ts
@@ -121,34 +121,47 @@ async function globalSetup(_config: FullConfig) {
const port = E2E_BASE_PORT + i;
const dbPath = `db-test-e2e-${i}.sqlite3`;
- // Ensure database exists with migrations
- if (!fs.existsSync(dbPath)) {
- // biome-ignore lint/suspicious/noConsole: CLI script output
- console.log(`Setting up database for worker ${i}: ${dbPath}`);
- execSync(`DB_PATH=${dbPath} pnpm run migrate up`, { stdio: "inherit" });
- }
+ // Ensure database has the latest migrations. The `start` script does this
+ // too but it is replicated here so the server spawn below can skip the
+ // script's inline env vars which do not work on Windows.
+ // biome-ignore lint/suspicious/noConsole: CLI script output
+ console.log(`Setting up database for worker ${i}: ${dbPath}`);
+ execSync("pnpm run migrate up", {
+ stdio: "inherit",
+ env: { ...process.env, DB_PATH: dbPath },
+ });
// Start server
// biome-ignore lint/suspicious/noConsole: CLI script output
console.log(`Starting server for worker ${i} on port ${port}...`);
- const serverProcess = spawn("pnpm", ["start"], {
- env: {
- ...process.env,
- DB_PATH: dbPath,
- PORT: String(port),
- DISCORD_CLIENT_ID: "123",
- DISCORD_CLIENT_SECRET: "secret",
- SESSION_SECRET: "secret",
- VITE_SITE_DOMAIN: `http://localhost:${port}`,
- VITE_E2E_TEST_RUN: "true",
- STORAGE_END_POINT: "http://127.0.0.1:9000",
- STORAGE_ACCESS_KEY: "minio-user",
- STORAGE_SECRET: "minio-password",
- STORAGE_REGION: "us-east-1",
- STORAGE_BUCKET: "sendou",
+ const serverProcess = spawn(
+ process.execPath,
+ [
+ "--import",
+ "./instrument.server.mjs",
+ "./node_modules/@react-router/serve/bin.cjs",
+ "./build/server/index.js",
+ ],
+ {
+ env: {
+ ...process.env,
+ NODE_ENV: "production",
+ DB_PATH: dbPath,
+ PORT: String(port),
+ DISCORD_CLIENT_ID: "123",
+ DISCORD_CLIENT_SECRET: "secret",
+ SESSION_SECRET: "secret",
+ VITE_SITE_DOMAIN: `http://localhost:${port}`,
+ VITE_E2E_TEST_RUN: "true",
+ STORAGE_END_POINT: "http://127.0.0.1:9000",
+ STORAGE_ACCESS_KEY: "minio-user",
+ STORAGE_SECRET: "minio-password",
+ STORAGE_REGION: "us-east-1",
+ STORAGE_BUCKET: "sendou",
+ },
+ detached: false,
},
- detached: false,
- });
+ );
SERVER_PROCESSES.push(serverProcess);
diff --git a/e2e/global-teardown.ts b/e2e/global-teardown.ts
index 10bcb3cf7..e8e2bb75e 100644
--- a/e2e/global-teardown.ts
+++ b/e2e/global-teardown.ts
@@ -16,7 +16,17 @@ async function globalTeardown(_config: FullConfig) {
for (const server of servers) {
if (server && !server.killed) {
- server.kill("SIGTERM");
+ if (process.platform === "win32" && server.pid) {
+ // the server is spawned through a shell on Windows so the whole
+ // process tree needs to be killed to not leave the ports occupied
+ try {
+ execSync(`taskkill /pid ${server.pid} /T /F`, { stdio: "pipe" });
+ } catch {
+ // Ignore errors, process might already be gone
+ }
+ } else {
+ server.kill("SIGTERM");
+ }
}
}
diff --git a/e2e/seeds/db-seed-AB_RR.sqlite3 b/e2e/seeds/db-seed-AB_RR.sqlite3
index d2dd5c2a4..163c3004b 100644
Binary files a/e2e/seeds/db-seed-AB_RR.sqlite3 and b/e2e/seeds/db-seed-AB_RR.sqlite3 differ
diff --git a/e2e/seeds/db-seed-DEFAULT.sqlite3 b/e2e/seeds/db-seed-DEFAULT.sqlite3
index 9ad8abde7..2fa89455e 100644
Binary files a/e2e/seeds/db-seed-DEFAULT.sqlite3 and b/e2e/seeds/db-seed-DEFAULT.sqlite3 differ
diff --git a/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 b/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3
index f653b9517..23b5f0b75 100644
Binary files a/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 and b/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 differ
diff --git a/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 b/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3
index c1bc29aff..0f2094823 100644
Binary files a/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 and b/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 differ
diff --git a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3
index 46b9d5752..028f446d0 100644
Binary files a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 and b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 differ
diff --git a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3
index c49e0b2dc..48165a0f8 100644
Binary files a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 and b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 differ
diff --git a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3
index 1d910b7e5..49b2f5d57 100644
Binary files a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 and b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 differ
diff --git a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3
index e9cb635e5..e63278787 100644
Binary files a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 and b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 differ
diff --git a/e2e/seeds/db-seed-REG_OPEN.sqlite3 b/e2e/seeds/db-seed-REG_OPEN.sqlite3
index 499db4d4b..0550c749f 100644
Binary files a/e2e/seeds/db-seed-REG_OPEN.sqlite3 and b/e2e/seeds/db-seed-REG_OPEN.sqlite3 differ
diff --git a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 b/e2e/seeds/db-seed-SMALL_SOS.sqlite3
index 60a60b647..c68d7ff6f 100644
Binary files a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 and b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 differ
diff --git a/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 b/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3
index 272c34f76..bf9d838ad 100644
Binary files a/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 and b/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 differ
diff --git a/e2e/trophies.spec.ts b/e2e/trophies.spec.ts
new file mode 100644
index 000000000..ae72bb55a
--- /dev/null
+++ b/e2e/trophies.spec.ts
@@ -0,0 +1,157 @@
+import { NZAP_TEST_ID } from "~/db/seed/constants";
+import trophies from "~/db/seed/trophies.json" with { type: "json" };
+import { ADMIN_DISCORD_ID } from "~/features/admin/admin-constants";
+import { decompressFromBase64 } from "~/utils/compression";
+import { NEW_TROPHY_PAGE, TROPHIES_PAGE, userPage } from "~/utils/urls";
+import {
+ expect,
+ impersonate,
+ isNotVisible,
+ navigate,
+ seed,
+ submit,
+ test,
+} from "./helpers/playwright";
+
+const VALID_TROPHY_MODEL =
+ decompressFromBase64(trophies["Chris P. Bacon"]) ?? "";
+
+test.describe("Trophies", () => {
+ test("hides trophies from users without early access", async ({ page }) => {
+ await seed(page);
+ await impersonate(page, NZAP_TEST_ID);
+
+ const response = await page.goto(TROPHIES_PAGE);
+ expect(response?.status()).toBe(404);
+
+ await navigate({
+ page,
+ url: userPage({ discordId: ADMIN_DISCORD_ID, customUrl: "sendou" }),
+ });
+ await isNotVisible(page.getByTestId("trophy-display"));
+ });
+
+ test("shows trophy wins via user page trophy display", async ({ page }) => {
+ await seed(page);
+ await impersonate(page);
+ await navigate({
+ page,
+ url: userPage({ discordId: ADMIN_DISCORD_ID, customUrl: "sendou" }),
+ });
+
+ await expect(page.getByTestId("trophy-display")).toBeVisible();
+ await page
+ .getByTestId("trophy-display")
+ .getByRole("button", { name: "Wellstring Wednesday" })
+ .click();
+
+ await expect(page.getByText("View trophy page")).toBeVisible();
+ await expect(
+ page.getByRole("dialog").locator("a[href^='/to/']").first(),
+ ).toBeVisible();
+ });
+
+ test("browses trophy details from the trophies list", async ({ page }) => {
+ await seed(page);
+ await impersonate(page);
+ await navigate({ page, url: TROPHIES_PAGE });
+
+ const trophyLinks = page.locator("a[href^='/trophies/']");
+ expect(await trophyLinks.count()).toBeGreaterThan(1);
+
+ await page.getByRole("textbox").fill("Chris P");
+ await expect(trophyLinks).toHaveCount(1);
+
+ await trophyLinks.first().click();
+ await expect(page).toHaveURL(/\/trophies\/\d+/);
+ await expect(page.getByText("Owners")).toBeVisible();
+ await expect(
+ page.locator("main").locator("a[href^='/u/']").first(),
+ ).toBeVisible();
+
+ await page.getByRole("textbox").fill("Wellstring");
+ await expect(
+ page.locator("a[href='/trophies/1']").getByTestId("tentative-tier"),
+ ).toBeVisible();
+ await expect(
+ page.locator("a[href='/trophies/1']").getByTestId("trophy-corner-pill"),
+ ).toBeVisible();
+ await page.locator("a[href='/trophies/1']").click();
+
+ const upcomingTournamentRow = page
+ .locator("main")
+ .locator("a[href='/to/10']");
+ await expect(upcomingTournamentRow.getByText("Upcoming")).toBeVisible();
+ await expect(upcomingTournamentRow.getByText("in 10 days")).toBeVisible();
+ });
+
+ test("submits a new trophy after agreeing to terms", async ({ page }) => {
+ await seed(page);
+ await impersonate(page);
+ await navigate({ page, url: NEW_TROPHY_PAGE });
+
+ const nameInput = page.getByLabel("Name").first();
+
+ await isNotVisible(nameInput);
+ await page.getByRole("button", { name: "I have read and agree" }).click();
+
+ await nameInput.fill("E2E Test Trophy");
+
+ await page.getByRole("button", { name: /organization/i }).click();
+ await page.getByTestId("organization-search-input").fill("sendou");
+ await page.getByTestId("organization-search-item").first().click();
+
+ await page.getByLabel("3D model state").fill(VALID_TROPHY_MODEL);
+
+ await submit(page);
+
+ await page.getByRole("tab", { name: "Pending" }).click();
+ await expect(page.getByText("E2E Test Trophy")).toBeVisible();
+ });
+
+ test("reviews pending trophies", async ({ page }) => {
+ await seed(page);
+ await impersonate(page);
+ await navigate({ page, url: NEW_TROPHY_PAGE });
+
+ await page.getByRole("tab", { name: "Pending" }).click();
+
+ await page
+ .getByText("Pending Chris P. Bacon 3")
+ .locator("../../..")
+ .getByRole("button", { name: "Approve" })
+ .click();
+ await expect(
+ page
+ .getByText("Pending Chris P. Bacon 3")
+ .locator("../../..")
+ .getByText("1/2 approvals"),
+ ).toBeVisible();
+
+ const declinedName = "Pending Wellstring Wednesday 1";
+ await page
+ .getByText(declinedName)
+ .locator("../../..")
+ .getByRole("button", { name: "Decline" })
+ .click();
+ await page
+ .getByRole("dialog")
+ .locator("textarea")
+ .fill("Does not meet the requirements");
+ await page
+ .getByRole("dialog")
+ .getByRole("button", { name: "Decline" })
+ .click();
+
+ await isNotVisible(page.getByText(declinedName));
+
+ await page.getByRole("tab", { name: "Reviewed" }).click();
+ await expect(page.getByText(declinedName)).toBeVisible();
+ await expect(
+ page
+ .getByText(declinedName)
+ .locator("../../..")
+ .getByText("Declined by Sendou"),
+ ).toBeVisible();
+ });
+});
diff --git a/knip.ts b/knip.ts
index 7597ba063..aadc82e26 100644
--- a/knip.ts
+++ b/knip.ts
@@ -4,7 +4,7 @@ const config = {
type: true,
},
tags: ["-lintignore"],
- ignoreBinaries: ["lsof"],
+ ignoreBinaries: ["lsof", "taskkill"],
// cwd relative path inside an execSync command, which knip resolves relative to the file instead
ignoreUnresolved: ["scripts/seed-single-variation.ts"],
entry: [
diff --git a/locales/da/calendar.json b/locales/da/calendar.json
index 6a7069a19..0581b40ba 100644
--- a/locales/da/calendar.json
+++ b/locales/da/calendar.json
@@ -20,6 +20,8 @@
"forms.tags.placeholder": "Vælg et tag",
"forms.badges": "Præmiemærker",
"forms.badges.placeholder": "Vælg et premiemærke",
+ "forms.trophy": "",
+ "forms.trophy.placeholder": "",
"forms.mapPool": "Banepulje",
"forms.reportResultsHeader": "Viser resultater for {{eventName}}",
"forms.reportResultsInfo": "Du vælger hvor mange resultater der skal vises. Det kan være det vindende hold eller top 3.",
diff --git a/locales/da/common.json b/locales/da/common.json
index f858ffcd3..ed626473b 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -2,6 +2,7 @@
"pages.admin": "Administratorer",
"pages.api": "",
"pages.articles": "Artikler",
+ "pages.trophies": "",
"pages.badges": "Mærker",
"pages.plus": "Plus Server",
"pages.contributors": "Bidragsydere",
@@ -49,6 +50,7 @@
"header.adder.art": "",
"header.adder.vod": "",
"header.adder.plusSuggestion": "",
+ "header.adder.trophy": "",
"notifications.title": "",
"notifications.empty": "",
"notifications.seeAll": "",
@@ -70,6 +72,12 @@
"notifications.text.BADGE_ADDED": "",
"notifications.title.BADGE_MANAGER_ADDED": "",
"notifications.text.BADGE_MANAGER_ADDED": "",
+ "notifications.title.TROPHY_SUBMITTED": "",
+ "notifications.text.TROPHY_SUBMITTED": "",
+ "notifications.title.TROPHY_SUBMISSION_ACCEPTED": "",
+ "notifications.text.TROPHY_SUBMISSION_ACCEPTED": "",
+ "notifications.title.TROPHY_SUBMISSION_DECLINED": "",
+ "notifications.text.TROPHY_SUBMISSION_DECLINED": "",
"notifications.title.PLUS_VOTING_STARTED": "",
"notifications.text.PLUS_VOTING_STARTED": "",
"notifications.title.PLUS_SUGGESTION_ADDED": "",
@@ -191,6 +199,8 @@
"forms.userSearch.noResults": "",
"forms.tournamentSearch.placeholder": "",
"forms.tournamentSearch.noResults": "",
+ "forms.organizationSearch.placeholder": "",
+ "forms.organizationSearch.noResults": "",
"forms.teamSearch.placeholder": "",
"forms.teamSearch.noResults": "",
"forms.weaponSearch.placeholder": "",
@@ -382,6 +392,9 @@
"badges.selector.none": "",
"badges.selector.select": "",
"badges.selector.noneAvailable": "",
+ "trophies.selector.none": "",
+ "trophies.selector.select": "",
+ "trophies.selector.noneAvailable": "",
"api.title": "",
"api.description": "",
"api.noAccess": "",
diff --git a/locales/da/forms.json b/locales/da/forms.json
index 9bd3bdd84..4508d4230 100644
--- a/locales/da/forms.json
+++ b/locales/da/forms.json
@@ -420,5 +420,15 @@
"placeholders.friendCode": "",
"unsavedChanges.title": "",
"unsavedChanges.body": "",
- "unsavedChanges.discard": ""
+ "unsavedChanges.discard": "",
+ "labels.trophyName": "",
+ "labels.trophyModel": "",
+ "labels.trophyOrganization": "",
+ "labels.trophyManager": "",
+ "labels.trophyInformation": "",
+ "labels.profileFavoriteTrophies": "",
+ "labels.profileHiddenTrophies": "",
+ "bottomTexts.trophyModel": "",
+ "errors.trophyNameTaken": "",
+ "errors.trophyWithBadges": ""
}
diff --git a/locales/da/org.json b/locales/da/org.json
index c4a8fa9df..95e15b4ce 100644
--- a/locales/da/org.json
+++ b/locales/da/org.json
@@ -20,6 +20,7 @@
"edit.form.series.seriesName.title": "",
"edit.form.series.showLeaderboard.title": "",
"edit.form.badges.title": "",
+ "edit.form.rewards.title": "",
"edit.form.errors.noUnadmin": "",
"banned.title": "",
"banned.empty": "",
diff --git a/locales/da/tournament.json b/locales/da/tournament.json
index b6d6edcd2..dfaf4eda4 100644
--- a/locales/da/tournament.json
+++ b/locales/da/tournament.json
@@ -134,6 +134,8 @@
"actions.finalize.error.BADGE_NOT_ASSIGNED": "",
"actions.finalize.error.DUPLICATE_TOURNAMENT_TEAM_ID": "",
"actions.finalize.error.BADGE_NOT_FOUND": "",
+ "actions.finalize.error.TROPHY_NOT_FOUND": "",
+ "actions.finalize.error.TROPHY_NOT_ASSIGNED": "",
"actions.finalize.assignBadgesLater": "",
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
diff --git a/locales/da/trophies.json b/locales/da/trophies.json
new file mode 100644
index 000000000..7c93cecb8
--- /dev/null
+++ b/locales/da/trophies.json
@@ -0,0 +1,67 @@
+{
+ "title": "",
+ "lookingForBadges": "",
+ "viewBadges": "",
+ "details.createdBy": "",
+ "details.managedBy": "",
+ "details.organization": "",
+ "details.owners": "",
+ "details.noOwners": "",
+ "details.tournamentHistory": "",
+ "details.noTournamentHistory": "",
+ "details.upcoming": "",
+ "display.viewTrophyPage": "",
+ "special.supporter.description": "",
+ "special.xp.description": "",
+ "new.form.limitReached": "",
+ "new.form.preview.light": "",
+ "new.form.preview.dark": "",
+ "new.specs.required": "",
+ "new.specs.recommended": "",
+ "new.specs.cameraTarget": "",
+ "new.specs.centered": "",
+ "new.specs.zoom": "",
+ "new.specs.angles": "",
+ "new.specs.background": "",
+ "new.specs.drawCalls": "",
+ "new.specs.polys": "",
+ "new.specs.effects": "",
+ "new.specs.currentValue": "",
+ "new.specs.stats.drawCalls": "",
+ "new.specs.stats.polys": "",
+ "new.specs.stats.effects": "",
+ "new.specs.stats.cameraTargetOff": "",
+ "new.specs.stats.backgroundNotAlpha": "",
+ "new.terms.title": "",
+ "new.terms.intro": "",
+ "new.terms.trustedOrg": "",
+ "new.terms.oneOff.title": "",
+ "new.terms.oneOff.pastTournaments": "",
+ "new.terms.oneOff.projectedTeams": "",
+ "new.terms.oneOff.signedUpTeams": "",
+ "new.terms.series.title": "",
+ "new.terms.series.consistentTeams": "",
+ "new.terms.disclaimer": "",
+ "new.terms.preApproval": "",
+ "new.terms.agree": "",
+ "new.tabs.upload": "",
+ "new.tabs.update": "",
+ "new.tabs.pending": "",
+ "new.tabs.reviewed": "",
+ "new.update.selectLabel": "",
+ "new.update.searchPlaceholder": "",
+ "new.update.editing": "",
+ "new.update.before": "",
+ "new.update.after": "",
+ "new.pending.empty": "",
+ "new.pending.notFromManager": "",
+ "new.pending.approve": "",
+ "new.pending.approved": "",
+ "new.pending.approvalProgress": "",
+ "new.pending.decline": "",
+ "new.pending.declineHeading": "",
+ "new.pending.declineReason": "",
+ "new.pending.declined": "",
+ "new.pending.declinedBy": "",
+ "new.reviewed.empty": ""
+}
diff --git a/locales/da/user.json b/locales/da/user.json
index c9a500c0e..a93cf6e42 100644
--- a/locales/da/user.json
+++ b/locales/da/user.json
@@ -3,6 +3,7 @@
"ign.short": "Splatnavn",
"widget.bio": "",
"widget.bio-md": "",
+ "widget.trophies-owned": "",
"widget.badges-owned": "",
"widget.badges-authored": "",
"widget.badges-managed": "",
@@ -60,6 +61,7 @@
"widgets.maxReached": "",
"widgets.search": "",
"widgets.category.misc": "",
+ "widgets.category.trophies": "",
"widgets.category.badges": "",
"widgets.category.teams": "",
"widgets.category.sendouq": "",
@@ -72,6 +74,7 @@
"widgets.category.game-badges": "",
"widgets.description.bio": "",
"widgets.description.bio-md": "",
+ "widgets.description.trophies-owned": "",
"widgets.description.badges-owned": "",
"widgets.description.badges-authored": "",
"widgets.description.badges-managed": "",
diff --git a/locales/de/calendar.json b/locales/de/calendar.json
index d2c63797d..d091720cb 100644
--- a/locales/de/calendar.json
+++ b/locales/de/calendar.json
@@ -20,6 +20,8 @@
"forms.tags.placeholder": "Wähle einen Tag",
"forms.badges": "Abzeichen-Preis",
"forms.badges.placeholder": "Wähle ein Abzeichen für das Event",
+ "forms.trophy": "",
+ "forms.trophy.placeholder": "",
"forms.mapPool": "Arenen-Pool",
"forms.reportResultsHeader": "Berichten der Ergebnisse von {{eventName}}",
"forms.reportResultsInfo": "Die Anzahl der eintragbaren Ergebnisse ist frei wählbar. Es kann nur das erste Team sein, die Top 3 oder mehr.",
diff --git a/locales/de/common.json b/locales/de/common.json
index 7f0b10f50..08c3d7504 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -2,6 +2,7 @@
"pages.admin": "Admin",
"pages.api": "",
"pages.articles": "Artikel",
+ "pages.trophies": "",
"pages.badges": "Abzeichen",
"pages.plus": "Plus Server",
"pages.contributors": "Mitwirkende",
@@ -49,6 +50,7 @@
"header.adder.art": "",
"header.adder.vod": "",
"header.adder.plusSuggestion": "",
+ "header.adder.trophy": "",
"notifications.title": "",
"notifications.empty": "",
"notifications.seeAll": "",
@@ -70,6 +72,12 @@
"notifications.text.BADGE_ADDED": "",
"notifications.title.BADGE_MANAGER_ADDED": "",
"notifications.text.BADGE_MANAGER_ADDED": "",
+ "notifications.title.TROPHY_SUBMITTED": "",
+ "notifications.text.TROPHY_SUBMITTED": "",
+ "notifications.title.TROPHY_SUBMISSION_ACCEPTED": "",
+ "notifications.text.TROPHY_SUBMISSION_ACCEPTED": "",
+ "notifications.title.TROPHY_SUBMISSION_DECLINED": "",
+ "notifications.text.TROPHY_SUBMISSION_DECLINED": "",
"notifications.title.PLUS_VOTING_STARTED": "",
"notifications.text.PLUS_VOTING_STARTED": "",
"notifications.title.PLUS_SUGGESTION_ADDED": "",
@@ -191,6 +199,8 @@
"forms.userSearch.noResults": "",
"forms.tournamentSearch.placeholder": "",
"forms.tournamentSearch.noResults": "",
+ "forms.organizationSearch.placeholder": "",
+ "forms.organizationSearch.noResults": "",
"forms.teamSearch.placeholder": "",
"forms.teamSearch.noResults": "",
"forms.weaponSearch.placeholder": "",
@@ -382,6 +392,9 @@
"badges.selector.none": "",
"badges.selector.select": "",
"badges.selector.noneAvailable": "",
+ "trophies.selector.none": "",
+ "trophies.selector.select": "",
+ "trophies.selector.noneAvailable": "",
"api.title": "",
"api.description": "",
"api.noAccess": "",
diff --git a/locales/de/forms.json b/locales/de/forms.json
index 1c99abee9..247bfdd6d 100644
--- a/locales/de/forms.json
+++ b/locales/de/forms.json
@@ -420,5 +420,15 @@
"placeholders.friendCode": "",
"unsavedChanges.title": "",
"unsavedChanges.body": "",
- "unsavedChanges.discard": ""
+ "unsavedChanges.discard": "",
+ "labels.trophyName": "",
+ "labels.trophyModel": "",
+ "labels.trophyOrganization": "",
+ "labels.trophyManager": "",
+ "labels.trophyInformation": "",
+ "labels.profileFavoriteTrophies": "",
+ "labels.profileHiddenTrophies": "",
+ "bottomTexts.trophyModel": "",
+ "errors.trophyNameTaken": "",
+ "errors.trophyWithBadges": ""
}
diff --git a/locales/de/org.json b/locales/de/org.json
index c4a8fa9df..95e15b4ce 100644
--- a/locales/de/org.json
+++ b/locales/de/org.json
@@ -20,6 +20,7 @@
"edit.form.series.seriesName.title": "",
"edit.form.series.showLeaderboard.title": "",
"edit.form.badges.title": "",
+ "edit.form.rewards.title": "",
"edit.form.errors.noUnadmin": "",
"banned.title": "",
"banned.empty": "",
diff --git a/locales/de/tournament.json b/locales/de/tournament.json
index 7e13e0d11..3d4d92f2f 100644
--- a/locales/de/tournament.json
+++ b/locales/de/tournament.json
@@ -134,6 +134,8 @@
"actions.finalize.error.BADGE_NOT_ASSIGNED": "",
"actions.finalize.error.DUPLICATE_TOURNAMENT_TEAM_ID": "",
"actions.finalize.error.BADGE_NOT_FOUND": "",
+ "actions.finalize.error.TROPHY_NOT_FOUND": "",
+ "actions.finalize.error.TROPHY_NOT_ASSIGNED": "",
"actions.finalize.assignBadgesLater": "",
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
diff --git a/locales/de/trophies.json b/locales/de/trophies.json
new file mode 100644
index 000000000..7c93cecb8
--- /dev/null
+++ b/locales/de/trophies.json
@@ -0,0 +1,67 @@
+{
+ "title": "",
+ "lookingForBadges": "",
+ "viewBadges": "",
+ "details.createdBy": "",
+ "details.managedBy": "",
+ "details.organization": "",
+ "details.owners": "",
+ "details.noOwners": "",
+ "details.tournamentHistory": "",
+ "details.noTournamentHistory": "",
+ "details.upcoming": "",
+ "display.viewTrophyPage": "",
+ "special.supporter.description": "",
+ "special.xp.description": "",
+ "new.form.limitReached": "",
+ "new.form.preview.light": "",
+ "new.form.preview.dark": "",
+ "new.specs.required": "",
+ "new.specs.recommended": "",
+ "new.specs.cameraTarget": "",
+ "new.specs.centered": "",
+ "new.specs.zoom": "",
+ "new.specs.angles": "",
+ "new.specs.background": "",
+ "new.specs.drawCalls": "",
+ "new.specs.polys": "",
+ "new.specs.effects": "",
+ "new.specs.currentValue": "",
+ "new.specs.stats.drawCalls": "",
+ "new.specs.stats.polys": "",
+ "new.specs.stats.effects": "",
+ "new.specs.stats.cameraTargetOff": "",
+ "new.specs.stats.backgroundNotAlpha": "",
+ "new.terms.title": "",
+ "new.terms.intro": "",
+ "new.terms.trustedOrg": "",
+ "new.terms.oneOff.title": "",
+ "new.terms.oneOff.pastTournaments": "",
+ "new.terms.oneOff.projectedTeams": "",
+ "new.terms.oneOff.signedUpTeams": "",
+ "new.terms.series.title": "",
+ "new.terms.series.consistentTeams": "",
+ "new.terms.disclaimer": "",
+ "new.terms.preApproval": "",
+ "new.terms.agree": "",
+ "new.tabs.upload": "",
+ "new.tabs.update": "",
+ "new.tabs.pending": "",
+ "new.tabs.reviewed": "",
+ "new.update.selectLabel": "",
+ "new.update.searchPlaceholder": "",
+ "new.update.editing": "",
+ "new.update.before": "",
+ "new.update.after": "",
+ "new.pending.empty": "",
+ "new.pending.notFromManager": "",
+ "new.pending.approve": "",
+ "new.pending.approved": "",
+ "new.pending.approvalProgress": "",
+ "new.pending.decline": "",
+ "new.pending.declineHeading": "",
+ "new.pending.declineReason": "",
+ "new.pending.declined": "",
+ "new.pending.declinedBy": "",
+ "new.reviewed.empty": ""
+}
diff --git a/locales/de/user.json b/locales/de/user.json
index e3e2afa05..1de21461a 100644
--- a/locales/de/user.json
+++ b/locales/de/user.json
@@ -3,6 +3,7 @@
"ign.short": "IGN",
"widget.bio": "",
"widget.bio-md": "",
+ "widget.trophies-owned": "",
"widget.badges-owned": "",
"widget.badges-authored": "",
"widget.badges-managed": "",
@@ -60,6 +61,7 @@
"widgets.maxReached": "",
"widgets.search": "",
"widgets.category.misc": "",
+ "widgets.category.trophies": "",
"widgets.category.badges": "",
"widgets.category.teams": "",
"widgets.category.sendouq": "",
@@ -72,6 +74,7 @@
"widgets.category.game-badges": "",
"widgets.description.bio": "",
"widgets.description.bio-md": "",
+ "widgets.description.trophies-owned": "",
"widgets.description.badges-owned": "",
"widgets.description.badges-authored": "",
"widgets.description.badges-managed": "",
diff --git a/locales/en/calendar.json b/locales/en/calendar.json
index d7ec808fd..249812045 100644
--- a/locales/en/calendar.json
+++ b/locales/en/calendar.json
@@ -20,6 +20,8 @@
"forms.tags.placeholder": "Choose a tag",
"forms.badges": "Badge prizes",
"forms.badges.placeholder": "Choose a badge prize",
+ "forms.trophy": "Trophy prize",
+ "forms.trophy.placeholder": "Choose a trophy prize",
"forms.mapPool": "Map pool",
"forms.reportResultsHeader": "Reporting results for {{eventName}}",
"forms.reportResultsInfo": "You choose how many results to report. It can be just the winning team, top 3 or whatever you decide.",
diff --git a/locales/en/common.json b/locales/en/common.json
index 25ae55f54..e868ddb7c 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -2,6 +2,7 @@
"pages.admin": "Admin",
"pages.api": "API",
"pages.articles": "Articles",
+ "pages.trophies": "Trophies",
"pages.badges": "Badges",
"pages.plus": "Plus Server",
"pages.contributors": "Contributors",
@@ -49,6 +50,7 @@
"header.adder.art": "Art",
"header.adder.vod": "VoD",
"header.adder.plusSuggestion": "Plus suggestion",
+ "header.adder.trophy": "Trophy",
"notifications.title": "Notifications",
"notifications.empty": "None yet, check back later",
"notifications.seeAll": "See all",
@@ -70,6 +72,12 @@
"notifications.text.BADGE_ADDED": "New badge ({{badgeName}})",
"notifications.title.BADGE_MANAGER_ADDED": "Manager Rights Granted",
"notifications.text.BADGE_MANAGER_ADDED": "You got manager rights for badge {{badgeName}}",
+ "notifications.title.TROPHY_SUBMITTED": "New Trophy Submission",
+ "notifications.text.TROPHY_SUBMITTED": "{{submitterUsername}} submitted trophy {{trophyName}} for review",
+ "notifications.title.TROPHY_SUBMISSION_ACCEPTED": "Trophy Accepted",
+ "notifications.text.TROPHY_SUBMISSION_ACCEPTED": "Your trophy {{trophyName}} was accepted",
+ "notifications.title.TROPHY_SUBMISSION_DECLINED": "Trophy Declined",
+ "notifications.text.TROPHY_SUBMISSION_DECLINED": "Your trophy {{trophyName}} was declined",
"notifications.title.PLUS_VOTING_STARTED": "Plus Voting Started",
"notifications.text.PLUS_VOTING_STARTED": "Plus Server voting of season {{seasonNth}} started",
"notifications.title.PLUS_SUGGESTION_ADDED": "Plus Suggestion Added",
@@ -191,6 +199,8 @@
"forms.userSearch.noResults": "No users matching your search found",
"forms.tournamentSearch.placeholder": "Search tournaments by name...",
"forms.tournamentSearch.noResults": "No tournaments matching your search found",
+ "forms.organizationSearch.placeholder": "Search organizations by name...",
+ "forms.organizationSearch.noResults": "No organizations matching your search found",
"forms.teamSearch.placeholder": "Search teams by name...",
"forms.teamSearch.noResults": "No teams matching your search found",
"forms.weaponSearch.placeholder": "Select a weapon",
@@ -382,6 +392,9 @@
"badges.selector.none": "No badges selected",
"badges.selector.select": "Select badge to add",
"badges.selector.noneAvailable": "No badges available. Organization members must be badge managers to add badges.",
+ "trophies.selector.none": "No trophies selected",
+ "trophies.selector.select": "Select trophy to add",
+ "trophies.selector.noneAvailable": "No trophies available.",
"api.title": "API Access",
"api.description": "Generate an API token to access the sendou.ink API. See the <1>API documentation1> for available endpoints, usage examples and guidelines to follow.",
"api.noAccess": "You do not have access to the API. Access is granted to supporters (Supporter tier or higher) and admins, organizers, or streamers of established tournament organizations.",
diff --git a/locales/en/forms.json b/locales/en/forms.json
index b45ea5f45..9379bb03f 100644
--- a/locales/en/forms.json
+++ b/locales/en/forms.json
@@ -420,5 +420,15 @@
"placeholders.friendCode": "1234-5678-9012",
"unsavedChanges.title": "Unsaved changes",
"unsavedChanges.body": "Are you sure you want to leave? Changes you made will not be saved.",
- "unsavedChanges.discard": "Leave page"
+ "unsavedChanges.discard": "Leave page",
+ "labels.trophyName": "Name",
+ "labels.trophyModel": "3D model state",
+ "labels.trophyOrganization": "Organization",
+ "labels.trophyManager": "Manager",
+ "labels.trophyInformation": "Additional information",
+ "labels.profileFavoriteTrophies": "Favorite trophies",
+ "labels.profileHiddenTrophies": "Hidden trophies",
+ "bottomTexts.trophyModel": "The 3D model state exported from the",
+ "errors.trophyNameTaken": "A trophy with this name already exists",
+ "errors.trophyWithBadges": "Cannot combine trophy and badges"
}
diff --git a/locales/en/org.json b/locales/en/org.json
index cbc571502..4fe5ee04d 100644
--- a/locales/en/org.json
+++ b/locales/en/org.json
@@ -20,6 +20,7 @@
"edit.form.series.seriesName.title": "Series name",
"edit.form.series.showLeaderboard.title": "Show leaderboard",
"edit.form.badges.title": "Badges",
+ "edit.form.rewards.title": "Rewards",
"edit.form.errors.noUnadmin": "Can't remove yourself as an admin",
"banned.title": "Banned players",
"banned.empty": "No players are currently banned from this organization.",
diff --git a/locales/en/tournament.json b/locales/en/tournament.json
index 675d43b0e..7f3b1c390 100644
--- a/locales/en/tournament.json
+++ b/locales/en/tournament.json
@@ -134,6 +134,8 @@
"actions.finalize.error.BADGE_NOT_ASSIGNED": "Not all badges have been assigned to teams",
"actions.finalize.error.DUPLICATE_TOURNAMENT_TEAM_ID": "Same badge assigned to multiple teams",
"actions.finalize.error.BADGE_NOT_FOUND": "Unexpected badge not found",
+ "actions.finalize.error.TROPHY_NOT_FOUND": "Unexpected trophy not found",
+ "actions.finalize.error.TROPHY_NOT_ASSIGNED": "Trophy has no receivers selected",
"actions.finalize.assignBadgesLater": "Assign badges later manually",
"finalize.receivingTeam.label": "Receiving team",
"finalize.receivingTeam.placeholder": "Select team…",
diff --git a/locales/en/trophies.json b/locales/en/trophies.json
new file mode 100644
index 000000000..caca2b156
--- /dev/null
+++ b/locales/en/trophies.json
@@ -0,0 +1,67 @@
+{
+ "title": "Trophies",
+ "lookingForBadges": "Looking for badges?",
+ "viewBadges": "View them here",
+ "details.createdBy": "Created by <1>{{name}}1>",
+ "details.managedBy": "Managed by <1>{{name}}1>",
+ "details.organization": "Organization: <1>{{name}}1>",
+ "details.owners": "Owners",
+ "details.noOwners": "No owners yet",
+ "details.tournamentHistory": "Tournaments",
+ "details.noTournamentHistory": "No tournament history",
+ "details.upcoming": "Upcoming",
+ "display.viewTrophyPage": "View trophy page",
+ "special.supporter.description": "Awarded for supporting sendou.ink",
+ "special.xp.description": "Awarded for reaching {{value}} X Power",
+ "new.form.limitReached": "You have reached the limit of {{limit}} pending trophies. Delete or wait for an existing submission to be reviewed before submitting another.",
+ "new.form.preview.light": "Light mode",
+ "new.form.preview.dark": "Dark mode",
+ "new.specs.required": "Required",
+ "new.specs.recommended": "Recommended",
+ "new.specs.cameraTarget": "Camera target X and Z must be 0",
+ "new.specs.centered": "Model must be visually centered on all axes",
+ "new.specs.zoom": "Zoom must be adjusted so the model fills out the viewport as much as possible without touching any edges",
+ "new.specs.angles": "Model must be viewable from all angles",
+ "new.specs.background": "Background color must be the alpha color",
+ "new.specs.drawCalls": "Max {{value}} draw calls",
+ "new.specs.polys": "Max {{value}} polys",
+ "new.specs.effects": "Max {{value}} effects",
+ "new.specs.currentValue": "(currently {{value}})",
+ "new.specs.stats.drawCalls": "{{value}} draw calls",
+ "new.specs.stats.polys": "{{value}} polys",
+ "new.specs.stats.effects": "{{value}} effects",
+ "new.specs.stats.cameraTargetOff": "Camera target not centered",
+ "new.specs.stats.backgroundNotAlpha": "Background is not the alpha color",
+ "new.terms.title": "Attention! Read the conditions before creating or commissioning a trophy model!",
+ "new.terms.intro": "You need to meet AT LEAST one of the following conditions for your trophy to be accepted:",
+ "new.terms.trustedOrg": "Is hosted by a trusted org",
+ "new.terms.oneOff.title": "If the trophy is for a one off tournament:",
+ "new.terms.oneOff.pastTournaments": "Have hosted tournaments in the past with 24 or more teams",
+ "new.terms.oneOff.projectedTeams": "Is projected to have at least 24 teams signed up for the tournament",
+ "new.terms.oneOff.signedUpTeams": "Already has 24 or more teams signed up for the tournament",
+ "new.terms.series.title": "If the trophy is for a tournament series:",
+ "new.terms.series.consistentTeams": "Consistently have 24 or more teams signed up",
+ "new.terms.disclaimer": "Meeting at least one condition does not automatically mean the trophy will be accepted!",
+ "new.terms.preApproval": "If you are unsure if your tournament is eligible, feel free to acquire a pre-approval by creating a pre-approval request in the <1>Discord server1>.",
+ "new.terms.agree": "I have read and agree",
+ "new.tabs.upload": "Upload",
+ "new.tabs.update": "Update",
+ "new.tabs.pending": "Pending",
+ "new.tabs.reviewed": "Reviewed",
+ "new.update.selectLabel": "Trophy to update",
+ "new.update.searchPlaceholder": "Search trophies",
+ "new.update.editing": "Editing existing trophy",
+ "new.update.before": "Before",
+ "new.update.after": "After",
+ "new.pending.empty": "No pending trophies",
+ "new.pending.notFromManager": "Not submitted by the trophy's manager",
+ "new.pending.approve": "Approve",
+ "new.pending.approved": "Approved",
+ "new.pending.approvalProgress": "{{current}}/{{required}} approvals",
+ "new.pending.decline": "Decline",
+ "new.pending.declineHeading": "Decline trophy",
+ "new.pending.declineReason": "Reason",
+ "new.pending.declined": "Declined",
+ "new.pending.declinedBy": "Declined by {{name}}",
+ "new.reviewed.empty": "No reviewed trophies"
+}
diff --git a/locales/en/user.json b/locales/en/user.json
index 556907376..e275369e5 100644
--- a/locales/en/user.json
+++ b/locales/en/user.json
@@ -3,6 +3,7 @@
"ign.short": "IGN",
"widget.bio": "Bio",
"widget.bio-md": "Bio",
+ "widget.trophies-owned": "Trophies",
"widget.badges-owned": "Badges",
"widget.badges-authored": "Badges Authored",
"widget.badges-managed": "Badges Managed",
@@ -60,6 +61,7 @@
"widgets.maxReached": "Maximum widgets reached",
"widgets.search": "Search widgets...",
"widgets.category.misc": "Miscellaneous",
+ "widgets.category.trophies": "Trophies",
"widgets.category.badges": "Badges",
"widgets.category.teams": "Teams",
"widgets.category.sendouq": "SendouQ",
@@ -72,6 +74,7 @@
"widgets.category.game-badges": "Game Badges",
"widgets.description.bio": "Share freeform information about yourself",
"widgets.description.bio-md": "Share freeform information about yourself with markdown formatting",
+ "widgets.description.trophies-owned": "Display your earned trophies",
"widgets.description.badges-owned": "Display your earned badges",
"widgets.description.badges-authored": "Display badges you've created",
"widgets.description.badges-managed": "Display badges you manage",
diff --git a/locales/es-ES/calendar.json b/locales/es-ES/calendar.json
index eb85751b0..8dffe7044 100644
--- a/locales/es-ES/calendar.json
+++ b/locales/es-ES/calendar.json
@@ -22,6 +22,8 @@
"forms.tags.placeholder": "Elegir etiquetas",
"forms.badges": "Premios de insignia",
"forms.badges.placeholder": "Elige un premio de insignia",
+ "forms.trophy": "",
+ "forms.trophy.placeholder": "",
"forms.mapPool": "Grupo de mapas",
"forms.reportResultsHeader": "Anunciando resultados para {{eventName}}",
"forms.reportResultsInfo": "Tú decides cuántos resultados reportar. Puede ser solo el equipo ganador, los 3 primeros o lo que quieras.",
diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json
index 6eff7d9c6..c1367a657 100644
--- a/locales/es-ES/common.json
+++ b/locales/es-ES/common.json
@@ -2,6 +2,7 @@
"pages.admin": "Administrador",
"pages.api": "API",
"pages.articles": "Artículos",
+ "pages.trophies": "",
"pages.badges": "Insignias",
"pages.plus": "Servidor Plus",
"pages.contributors": "Colaboradores",
@@ -49,6 +50,7 @@
"header.adder.art": "Arte",
"header.adder.vod": "VoD",
"header.adder.plusSuggestion": "Sugerencia Plus",
+ "header.adder.trophy": "",
"notifications.title": "Notificaciones",
"notifications.empty": "Nada aún, vuelve más tarde",
"notifications.seeAll": "Ver todas",
@@ -70,6 +72,12 @@
"notifications.text.BADGE_ADDED": "Nueva insignia ({{badgeName}})",
"notifications.title.BADGE_MANAGER_ADDED": "Derechos de Gestor Concedidos",
"notifications.text.BADGE_MANAGER_ADDED": "Tienes derechos de gestor para la insignia {{badgeName}}",
+ "notifications.title.TROPHY_SUBMITTED": "",
+ "notifications.text.TROPHY_SUBMITTED": "",
+ "notifications.title.TROPHY_SUBMISSION_ACCEPTED": "",
+ "notifications.text.TROPHY_SUBMISSION_ACCEPTED": "",
+ "notifications.title.TROPHY_SUBMISSION_DECLINED": "",
+ "notifications.text.TROPHY_SUBMISSION_DECLINED": "",
"notifications.title.PLUS_VOTING_STARTED": "Votación Plus Iniciada",
"notifications.text.PLUS_VOTING_STARTED": "La votación del Servidor Plus de la temporada {{seasonNth}} ha comenzado",
"notifications.title.PLUS_SUGGESTION_ADDED": "Sugerencia Plus Añadida",
@@ -191,6 +199,8 @@
"forms.userSearch.noResults": "Ningún usuario que coincida con tu búsqueda",
"forms.tournamentSearch.placeholder": "Buscar torneos por nombre...",
"forms.tournamentSearch.noResults": "Ningún torneo que coincida con tu búsqueda",
+ "forms.organizationSearch.placeholder": "",
+ "forms.organizationSearch.noResults": "",
"forms.teamSearch.placeholder": "",
"forms.teamSearch.noResults": "",
"forms.weaponSearch.placeholder": "Selecciona un arma",
@@ -382,6 +392,9 @@
"badges.selector.none": "Ninguna insignia seleccionada",
"badges.selector.select": "Seleccionar insignia a añadir",
"badges.selector.noneAvailable": "No hay insignias disponibles. Los miembros de la organización deben ser gestores de insignias para poder añadirlas.",
+ "trophies.selector.none": "",
+ "trophies.selector.select": "",
+ "trophies.selector.noneAvailable": "",
"api.title": "Acceso a la API",
"api.description": "Genera un token para acceder a la API de sendou.ink. Consulta la <1>documentación1> para ver ejemplos.",
"api.noAccess": "No tienes acceso a la API. El acceso se concede a seguidores (nivel Supporter o superior) y a admins, organizadores o streamers de organizaciones de torneos establecidas.",
diff --git a/locales/es-ES/forms.json b/locales/es-ES/forms.json
index b0834196d..7ae96fea9 100644
--- a/locales/es-ES/forms.json
+++ b/locales/es-ES/forms.json
@@ -420,5 +420,15 @@
"placeholders.friendCode": "",
"unsavedChanges.title": "",
"unsavedChanges.body": "",
- "unsavedChanges.discard": ""
+ "unsavedChanges.discard": "",
+ "labels.trophyName": "",
+ "labels.trophyModel": "",
+ "labels.trophyOrganization": "",
+ "labels.trophyManager": "",
+ "labels.trophyInformation": "",
+ "labels.profileFavoriteTrophies": "",
+ "labels.profileHiddenTrophies": "",
+ "bottomTexts.trophyModel": "",
+ "errors.trophyNameTaken": "",
+ "errors.trophyWithBadges": ""
}
diff --git a/locales/es-ES/org.json b/locales/es-ES/org.json
index dbc7a0038..c32e5d301 100644
--- a/locales/es-ES/org.json
+++ b/locales/es-ES/org.json
@@ -20,6 +20,7 @@
"edit.form.series.seriesName.title": "Nombre de la serie",
"edit.form.series.showLeaderboard.title": "Mostrar tablas de posición",
"edit.form.badges.title": "Insignias",
+ "edit.form.rewards.title": "",
"edit.form.errors.noUnadmin": "No se puede eliminar como admin",
"banned.title": "Jugadores baneados",
"banned.empty": "Actualmente no hay jugadores baneados en esta organización.",
diff --git a/locales/es-ES/tournament.json b/locales/es-ES/tournament.json
index 3308d75b6..1b55f4b36 100644
--- a/locales/es-ES/tournament.json
+++ b/locales/es-ES/tournament.json
@@ -136,6 +136,8 @@
"actions.finalize.error.BADGE_NOT_ASSIGNED": "No todas las insignias han sido asignadas a equipos",
"actions.finalize.error.DUPLICATE_TOURNAMENT_TEAM_ID": "La misma insignia fue asignada a varios equipos",
"actions.finalize.error.BADGE_NOT_FOUND": "Insignia inesperada no encontrada",
+ "actions.finalize.error.TROPHY_NOT_FOUND": "",
+ "actions.finalize.error.TROPHY_NOT_ASSIGNED": "",
"actions.finalize.assignBadgesLater": "Asignar insignias manualmente más tarde",
"finalize.receivingTeam.label": "Equipo receptor",
"finalize.receivingTeam.placeholder": "Seleccionar equipo...",
diff --git a/locales/es-ES/trophies.json b/locales/es-ES/trophies.json
new file mode 100644
index 000000000..7c93cecb8
--- /dev/null
+++ b/locales/es-ES/trophies.json
@@ -0,0 +1,67 @@
+{
+ "title": "",
+ "lookingForBadges": "",
+ "viewBadges": "",
+ "details.createdBy": "",
+ "details.managedBy": "",
+ "details.organization": "",
+ "details.owners": "",
+ "details.noOwners": "",
+ "details.tournamentHistory": "",
+ "details.noTournamentHistory": "",
+ "details.upcoming": "",
+ "display.viewTrophyPage": "",
+ "special.supporter.description": "",
+ "special.xp.description": "",
+ "new.form.limitReached": "",
+ "new.form.preview.light": "",
+ "new.form.preview.dark": "",
+ "new.specs.required": "",
+ "new.specs.recommended": "",
+ "new.specs.cameraTarget": "",
+ "new.specs.centered": "",
+ "new.specs.zoom": "",
+ "new.specs.angles": "",
+ "new.specs.background": "",
+ "new.specs.drawCalls": "",
+ "new.specs.polys": "",
+ "new.specs.effects": "",
+ "new.specs.currentValue": "",
+ "new.specs.stats.drawCalls": "",
+ "new.specs.stats.polys": "",
+ "new.specs.stats.effects": "",
+ "new.specs.stats.cameraTargetOff": "",
+ "new.specs.stats.backgroundNotAlpha": "",
+ "new.terms.title": "",
+ "new.terms.intro": "",
+ "new.terms.trustedOrg": "",
+ "new.terms.oneOff.title": "",
+ "new.terms.oneOff.pastTournaments": "",
+ "new.terms.oneOff.projectedTeams": "",
+ "new.terms.oneOff.signedUpTeams": "",
+ "new.terms.series.title": "",
+ "new.terms.series.consistentTeams": "",
+ "new.terms.disclaimer": "",
+ "new.terms.preApproval": "",
+ "new.terms.agree": "",
+ "new.tabs.upload": "",
+ "new.tabs.update": "",
+ "new.tabs.pending": "",
+ "new.tabs.reviewed": "",
+ "new.update.selectLabel": "",
+ "new.update.searchPlaceholder": "",
+ "new.update.editing": "",
+ "new.update.before": "",
+ "new.update.after": "",
+ "new.pending.empty": "",
+ "new.pending.notFromManager": "",
+ "new.pending.approve": "",
+ "new.pending.approved": "",
+ "new.pending.approvalProgress": "",
+ "new.pending.decline": "",
+ "new.pending.declineHeading": "",
+ "new.pending.declineReason": "",
+ "new.pending.declined": "",
+ "new.pending.declinedBy": "",
+ "new.reviewed.empty": ""
+}
diff --git a/locales/es-ES/user.json b/locales/es-ES/user.json
index 5e0296a8d..c8869db9f 100644
--- a/locales/es-ES/user.json
+++ b/locales/es-ES/user.json
@@ -3,6 +3,7 @@
"ign.short": "IGN",
"widget.bio": "Biografía",
"widget.bio-md": "Biografía",
+ "widget.trophies-owned": "",
"widget.badges-owned": "Insignias",
"widget.badges-authored": "Insignias creadas",
"widget.badges-managed": "Insignias gestionadas",
@@ -60,6 +61,7 @@
"widgets.maxReached": "Límite máximo de widgets alcanzado",
"widgets.search": "Buscar widgets...",
"widgets.category.misc": "Miscelánea",
+ "widgets.category.trophies": "",
"widgets.category.badges": "Insignias",
"widgets.category.teams": "Equipos",
"widgets.category.sendouq": "SendouQ",
@@ -72,6 +74,7 @@
"widgets.category.game-badges": "",
"widgets.description.bio": "Comparte información libre sobre ti",
"widgets.description.bio-md": "Comparte información libre sobre ti usando formato Markdown",
+ "widgets.description.trophies-owned": "",
"widgets.description.badges-owned": "Muestra las insignias que has conseguido",
"widgets.description.badges-authored": "Muestra las insignias que has creado",
"widgets.description.badges-managed": "Muestra las insignias que gestionas",
diff --git a/locales/es-US/calendar.json b/locales/es-US/calendar.json
index c9cd66ce0..826032e49 100644
--- a/locales/es-US/calendar.json
+++ b/locales/es-US/calendar.json
@@ -22,6 +22,8 @@
"forms.tags.placeholder": "Elegir etiquetas",
"forms.badges": "Premios de insignia",
"forms.badges.placeholder": "Elige un premio de insignia",
+ "forms.trophy": "",
+ "forms.trophy.placeholder": "",
"forms.mapPool": "Grupo de mapas",
"forms.reportResultsHeader": "Anunciando resultados para {{eventName}}",
"forms.reportResultsInfo": "Tú decides cuántos resultados reportar. Puede ser solo el equipo ganador, los 3 primeros o lo que quieras.",
diff --git a/locales/es-US/common.json b/locales/es-US/common.json
index ef6d0e38f..eb9cc1e13 100644
--- a/locales/es-US/common.json
+++ b/locales/es-US/common.json
@@ -2,6 +2,7 @@
"pages.admin": "Administración",
"pages.api": "",
"pages.articles": "Artículos",
+ "pages.trophies": "",
"pages.badges": "Insignias",
"pages.plus": "Plus Server",
"pages.contributors": "Contribuidores",
@@ -49,6 +50,7 @@
"header.adder.art": "",
"header.adder.vod": "",
"header.adder.plusSuggestion": "",
+ "header.adder.trophy": "",
"notifications.title": "",
"notifications.empty": "",
"notifications.seeAll": "",
@@ -70,6 +72,12 @@
"notifications.text.BADGE_ADDED": "",
"notifications.title.BADGE_MANAGER_ADDED": "",
"notifications.text.BADGE_MANAGER_ADDED": "",
+ "notifications.title.TROPHY_SUBMITTED": "",
+ "notifications.text.TROPHY_SUBMITTED": "",
+ "notifications.title.TROPHY_SUBMISSION_ACCEPTED": "",
+ "notifications.text.TROPHY_SUBMISSION_ACCEPTED": "",
+ "notifications.title.TROPHY_SUBMISSION_DECLINED": "",
+ "notifications.text.TROPHY_SUBMISSION_DECLINED": "",
"notifications.title.PLUS_VOTING_STARTED": "",
"notifications.text.PLUS_VOTING_STARTED": "",
"notifications.title.PLUS_SUGGESTION_ADDED": "",
@@ -191,6 +199,8 @@
"forms.userSearch.noResults": "",
"forms.tournamentSearch.placeholder": "",
"forms.tournamentSearch.noResults": "",
+ "forms.organizationSearch.placeholder": "",
+ "forms.organizationSearch.noResults": "",
"forms.teamSearch.placeholder": "",
"forms.teamSearch.noResults": "",
"forms.weaponSearch.placeholder": "",
@@ -382,6 +392,9 @@
"badges.selector.none": "Ninguna insignia seleccionada",
"badges.selector.select": "Seleccionar insignia añadir",
"badges.selector.noneAvailable": "",
+ "trophies.selector.none": "",
+ "trophies.selector.select": "",
+ "trophies.selector.noneAvailable": "",
"api.title": "",
"api.description": "",
"api.noAccess": "",
diff --git a/locales/es-US/forms.json b/locales/es-US/forms.json
index 10c05eed4..5f8f2d7ca 100644
--- a/locales/es-US/forms.json
+++ b/locales/es-US/forms.json
@@ -420,5 +420,15 @@
"placeholders.friendCode": "",
"unsavedChanges.title": "",
"unsavedChanges.body": "",
- "unsavedChanges.discard": ""
+ "unsavedChanges.discard": "",
+ "labels.trophyName": "",
+ "labels.trophyModel": "",
+ "labels.trophyOrganization": "",
+ "labels.trophyManager": "",
+ "labels.trophyInformation": "",
+ "labels.profileFavoriteTrophies": "",
+ "labels.profileHiddenTrophies": "",
+ "bottomTexts.trophyModel": "",
+ "errors.trophyNameTaken": "",
+ "errors.trophyWithBadges": ""
}
diff --git a/locales/es-US/org.json b/locales/es-US/org.json
index 02e055556..f0fff0396 100644
--- a/locales/es-US/org.json
+++ b/locales/es-US/org.json
@@ -20,6 +20,7 @@
"edit.form.series.seriesName.title": "Nombre de la serie",
"edit.form.series.showLeaderboard.title": "Mostrar tablas de posición",
"edit.form.badges.title": "Insignias",
+ "edit.form.rewards.title": "",
"edit.form.errors.noUnadmin": "No se puede eliminar como admin",
"banned.title": "",
"banned.empty": "",
diff --git a/locales/es-US/tournament.json b/locales/es-US/tournament.json
index cc1a36f08..730191641 100644
--- a/locales/es-US/tournament.json
+++ b/locales/es-US/tournament.json
@@ -136,6 +136,8 @@
"actions.finalize.error.BADGE_NOT_ASSIGNED": "",
"actions.finalize.error.DUPLICATE_TOURNAMENT_TEAM_ID": "",
"actions.finalize.error.BADGE_NOT_FOUND": "",
+ "actions.finalize.error.TROPHY_NOT_FOUND": "",
+ "actions.finalize.error.TROPHY_NOT_ASSIGNED": "",
"actions.finalize.assignBadgesLater": "",
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
diff --git a/locales/es-US/trophies.json b/locales/es-US/trophies.json
new file mode 100644
index 000000000..7c93cecb8
--- /dev/null
+++ b/locales/es-US/trophies.json
@@ -0,0 +1,67 @@
+{
+ "title": "",
+ "lookingForBadges": "",
+ "viewBadges": "",
+ "details.createdBy": "",
+ "details.managedBy": "",
+ "details.organization": "",
+ "details.owners": "",
+ "details.noOwners": "",
+ "details.tournamentHistory": "",
+ "details.noTournamentHistory": "",
+ "details.upcoming": "",
+ "display.viewTrophyPage": "",
+ "special.supporter.description": "",
+ "special.xp.description": "",
+ "new.form.limitReached": "",
+ "new.form.preview.light": "",
+ "new.form.preview.dark": "",
+ "new.specs.required": "",
+ "new.specs.recommended": "",
+ "new.specs.cameraTarget": "",
+ "new.specs.centered": "",
+ "new.specs.zoom": "",
+ "new.specs.angles": "",
+ "new.specs.background": "",
+ "new.specs.drawCalls": "",
+ "new.specs.polys": "",
+ "new.specs.effects": "",
+ "new.specs.currentValue": "",
+ "new.specs.stats.drawCalls": "",
+ "new.specs.stats.polys": "",
+ "new.specs.stats.effects": "",
+ "new.specs.stats.cameraTargetOff": "",
+ "new.specs.stats.backgroundNotAlpha": "",
+ "new.terms.title": "",
+ "new.terms.intro": "",
+ "new.terms.trustedOrg": "",
+ "new.terms.oneOff.title": "",
+ "new.terms.oneOff.pastTournaments": "",
+ "new.terms.oneOff.projectedTeams": "",
+ "new.terms.oneOff.signedUpTeams": "",
+ "new.terms.series.title": "",
+ "new.terms.series.consistentTeams": "",
+ "new.terms.disclaimer": "",
+ "new.terms.preApproval": "",
+ "new.terms.agree": "",
+ "new.tabs.upload": "",
+ "new.tabs.update": "",
+ "new.tabs.pending": "",
+ "new.tabs.reviewed": "",
+ "new.update.selectLabel": "",
+ "new.update.searchPlaceholder": "",
+ "new.update.editing": "",
+ "new.update.before": "",
+ "new.update.after": "",
+ "new.pending.empty": "",
+ "new.pending.notFromManager": "",
+ "new.pending.approve": "",
+ "new.pending.approved": "",
+ "new.pending.approvalProgress": "",
+ "new.pending.decline": "",
+ "new.pending.declineHeading": "",
+ "new.pending.declineReason": "",
+ "new.pending.declined": "",
+ "new.pending.declinedBy": "",
+ "new.reviewed.empty": ""
+}
diff --git a/locales/es-US/user.json b/locales/es-US/user.json
index abe4f287c..21358adf4 100644
--- a/locales/es-US/user.json
+++ b/locales/es-US/user.json
@@ -3,6 +3,7 @@
"ign.short": "IGN",
"widget.bio": "",
"widget.bio-md": "",
+ "widget.trophies-owned": "",
"widget.badges-owned": "",
"widget.badges-authored": "",
"widget.badges-managed": "",
@@ -60,6 +61,7 @@
"widgets.maxReached": "",
"widgets.search": "",
"widgets.category.misc": "",
+ "widgets.category.trophies": "",
"widgets.category.badges": "",
"widgets.category.teams": "",
"widgets.category.sendouq": "",
@@ -72,6 +74,7 @@
"widgets.category.game-badges": "",
"widgets.description.bio": "",
"widgets.description.bio-md": "",
+ "widgets.description.trophies-owned": "",
"widgets.description.badges-owned": "",
"widgets.description.badges-authored": "",
"widgets.description.badges-managed": "",
diff --git a/locales/fr-CA/calendar.json b/locales/fr-CA/calendar.json
index 9f762de6a..9eaea9e94 100644
--- a/locales/fr-CA/calendar.json
+++ b/locales/fr-CA/calendar.json
@@ -22,6 +22,8 @@
"forms.tags.placeholder": "Choisir un tag",
"forms.badges": "Badge à gagner",
"forms.badges.placeholder": "Choisissez le badge à faire gagner",
+ "forms.trophy": "",
+ "forms.trophy.placeholder": "",
"forms.mapPool": "Stages disponibles",
"forms.reportResultsHeader": "Déclarer les résultats pour {{eventName}}",
"forms.reportResultsInfo": "Les résultats déclarés peuvent être personnalisés. Vous pouvez déclarer uniquement le gagnant, le top 3, ou tout ce que vous décidez.",
diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json
index b9d0a0bc9..cc195a8ae 100644
--- a/locales/fr-CA/common.json
+++ b/locales/fr-CA/common.json
@@ -2,6 +2,7 @@
"pages.admin": "Admin",
"pages.api": "",
"pages.articles": "Articles",
+ "pages.trophies": "",
"pages.badges": "Badges",
"pages.plus": "Plus Server",
"pages.contributors": "Contributeurs",
@@ -49,6 +50,7 @@
"header.adder.art": "",
"header.adder.vod": "",
"header.adder.plusSuggestion": "",
+ "header.adder.trophy": "",
"notifications.title": "",
"notifications.empty": "",
"notifications.seeAll": "",
@@ -70,6 +72,12 @@
"notifications.text.BADGE_ADDED": "",
"notifications.title.BADGE_MANAGER_ADDED": "",
"notifications.text.BADGE_MANAGER_ADDED": "",
+ "notifications.title.TROPHY_SUBMITTED": "",
+ "notifications.text.TROPHY_SUBMITTED": "",
+ "notifications.title.TROPHY_SUBMISSION_ACCEPTED": "",
+ "notifications.text.TROPHY_SUBMISSION_ACCEPTED": "",
+ "notifications.title.TROPHY_SUBMISSION_DECLINED": "",
+ "notifications.text.TROPHY_SUBMISSION_DECLINED": "",
"notifications.title.PLUS_VOTING_STARTED": "",
"notifications.text.PLUS_VOTING_STARTED": "",
"notifications.title.PLUS_SUGGESTION_ADDED": "",
@@ -191,6 +199,8 @@
"forms.userSearch.noResults": "",
"forms.tournamentSearch.placeholder": "",
"forms.tournamentSearch.noResults": "",
+ "forms.organizationSearch.placeholder": "",
+ "forms.organizationSearch.noResults": "",
"forms.teamSearch.placeholder": "",
"forms.teamSearch.noResults": "",
"forms.weaponSearch.placeholder": "",
@@ -382,6 +392,9 @@
"badges.selector.none": "",
"badges.selector.select": "",
"badges.selector.noneAvailable": "",
+ "trophies.selector.none": "",
+ "trophies.selector.select": "",
+ "trophies.selector.noneAvailable": "",
"api.title": "",
"api.description": "",
"api.noAccess": "",
diff --git a/locales/fr-CA/forms.json b/locales/fr-CA/forms.json
index b5eb73676..8cd783eb1 100644
--- a/locales/fr-CA/forms.json
+++ b/locales/fr-CA/forms.json
@@ -420,5 +420,15 @@
"placeholders.friendCode": "",
"unsavedChanges.title": "",
"unsavedChanges.body": "",
- "unsavedChanges.discard": ""
+ "unsavedChanges.discard": "",
+ "labels.trophyName": "",
+ "labels.trophyModel": "",
+ "labels.trophyOrganization": "",
+ "labels.trophyManager": "",
+ "labels.trophyInformation": "",
+ "labels.profileFavoriteTrophies": "",
+ "labels.profileHiddenTrophies": "",
+ "bottomTexts.trophyModel": "",
+ "errors.trophyNameTaken": "",
+ "errors.trophyWithBadges": ""
}
diff --git a/locales/fr-CA/org.json b/locales/fr-CA/org.json
index c4a8fa9df..95e15b4ce 100644
--- a/locales/fr-CA/org.json
+++ b/locales/fr-CA/org.json
@@ -20,6 +20,7 @@
"edit.form.series.seriesName.title": "",
"edit.form.series.showLeaderboard.title": "",
"edit.form.badges.title": "",
+ "edit.form.rewards.title": "",
"edit.form.errors.noUnadmin": "",
"banned.title": "",
"banned.empty": "",
diff --git a/locales/fr-CA/tournament.json b/locales/fr-CA/tournament.json
index a2423508c..dfc02bfc7 100644
--- a/locales/fr-CA/tournament.json
+++ b/locales/fr-CA/tournament.json
@@ -136,6 +136,8 @@
"actions.finalize.error.BADGE_NOT_ASSIGNED": "",
"actions.finalize.error.DUPLICATE_TOURNAMENT_TEAM_ID": "",
"actions.finalize.error.BADGE_NOT_FOUND": "",
+ "actions.finalize.error.TROPHY_NOT_FOUND": "",
+ "actions.finalize.error.TROPHY_NOT_ASSIGNED": "",
"actions.finalize.assignBadgesLater": "",
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
diff --git a/locales/fr-CA/trophies.json b/locales/fr-CA/trophies.json
new file mode 100644
index 000000000..7c93cecb8
--- /dev/null
+++ b/locales/fr-CA/trophies.json
@@ -0,0 +1,67 @@
+{
+ "title": "",
+ "lookingForBadges": "",
+ "viewBadges": "",
+ "details.createdBy": "",
+ "details.managedBy": "",
+ "details.organization": "",
+ "details.owners": "",
+ "details.noOwners": "",
+ "details.tournamentHistory": "",
+ "details.noTournamentHistory": "",
+ "details.upcoming": "",
+ "display.viewTrophyPage": "",
+ "special.supporter.description": "",
+ "special.xp.description": "",
+ "new.form.limitReached": "",
+ "new.form.preview.light": "",
+ "new.form.preview.dark": "",
+ "new.specs.required": "",
+ "new.specs.recommended": "",
+ "new.specs.cameraTarget": "",
+ "new.specs.centered": "",
+ "new.specs.zoom": "",
+ "new.specs.angles": "",
+ "new.specs.background": "",
+ "new.specs.drawCalls": "",
+ "new.specs.polys": "",
+ "new.specs.effects": "",
+ "new.specs.currentValue": "",
+ "new.specs.stats.drawCalls": "",
+ "new.specs.stats.polys": "",
+ "new.specs.stats.effects": "",
+ "new.specs.stats.cameraTargetOff": "",
+ "new.specs.stats.backgroundNotAlpha": "",
+ "new.terms.title": "",
+ "new.terms.intro": "",
+ "new.terms.trustedOrg": "",
+ "new.terms.oneOff.title": "",
+ "new.terms.oneOff.pastTournaments": "",
+ "new.terms.oneOff.projectedTeams": "",
+ "new.terms.oneOff.signedUpTeams": "",
+ "new.terms.series.title": "",
+ "new.terms.series.consistentTeams": "",
+ "new.terms.disclaimer": "",
+ "new.terms.preApproval": "",
+ "new.terms.agree": "",
+ "new.tabs.upload": "",
+ "new.tabs.update": "",
+ "new.tabs.pending": "",
+ "new.tabs.reviewed": "",
+ "new.update.selectLabel": "",
+ "new.update.searchPlaceholder": "",
+ "new.update.editing": "",
+ "new.update.before": "",
+ "new.update.after": "",
+ "new.pending.empty": "",
+ "new.pending.notFromManager": "",
+ "new.pending.approve": "",
+ "new.pending.approved": "",
+ "new.pending.approvalProgress": "",
+ "new.pending.decline": "",
+ "new.pending.declineHeading": "",
+ "new.pending.declineReason": "",
+ "new.pending.declined": "",
+ "new.pending.declinedBy": "",
+ "new.reviewed.empty": ""
+}
diff --git a/locales/fr-CA/user.json b/locales/fr-CA/user.json
index b4b8eb7b6..0ebccd81a 100644
--- a/locales/fr-CA/user.json
+++ b/locales/fr-CA/user.json
@@ -3,6 +3,7 @@
"ign.short": "PEJ",
"widget.bio": "",
"widget.bio-md": "",
+ "widget.trophies-owned": "",
"widget.badges-owned": "",
"widget.badges-authored": "",
"widget.badges-managed": "",
@@ -60,6 +61,7 @@
"widgets.maxReached": "",
"widgets.search": "",
"widgets.category.misc": "",
+ "widgets.category.trophies": "",
"widgets.category.badges": "",
"widgets.category.teams": "",
"widgets.category.sendouq": "",
@@ -72,6 +74,7 @@
"widgets.category.game-badges": "",
"widgets.description.bio": "",
"widgets.description.bio-md": "",
+ "widgets.description.trophies-owned": "",
"widgets.description.badges-owned": "",
"widgets.description.badges-authored": "",
"widgets.description.badges-managed": "",
diff --git a/locales/fr-EU/calendar.json b/locales/fr-EU/calendar.json
index 9f762de6a..9eaea9e94 100644
--- a/locales/fr-EU/calendar.json
+++ b/locales/fr-EU/calendar.json
@@ -22,6 +22,8 @@
"forms.tags.placeholder": "Choisir un tag",
"forms.badges": "Badge à gagner",
"forms.badges.placeholder": "Choisissez le badge à faire gagner",
+ "forms.trophy": "",
+ "forms.trophy.placeholder": "",
"forms.mapPool": "Stages disponibles",
"forms.reportResultsHeader": "Déclarer les résultats pour {{eventName}}",
"forms.reportResultsInfo": "Les résultats déclarés peuvent être personnalisés. Vous pouvez déclarer uniquement le gagnant, le top 3, ou tout ce que vous décidez.",
diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json
index 6a8b0258e..109bbe446 100644
--- a/locales/fr-EU/common.json
+++ b/locales/fr-EU/common.json
@@ -2,6 +2,7 @@
"pages.admin": "Admin",
"pages.api": "",
"pages.articles": "Articles",
+ "pages.trophies": "",
"pages.badges": "Badges",
"pages.plus": "Plus Serveur",
"pages.contributors": "Contributeurs",
@@ -49,6 +50,7 @@
"header.adder.art": "Art",
"header.adder.vod": "VoD",
"header.adder.plusSuggestion": "Suggestion au Plus",
+ "header.adder.trophy": "",
"notifications.title": "Notification",
"notifications.empty": "Rien ici encore, revenez plus tard",
"notifications.seeAll": "Voir tout",
@@ -70,6 +72,12 @@
"notifications.text.BADGE_ADDED": "Nouveau badge: ({{badgeName}})",
"notifications.title.BADGE_MANAGER_ADDED": "Droits de gestion accordé",
"notifications.text.BADGE_MANAGER_ADDED": "Vous avez les droits de gestion pour le badge: {{badgeName}}",
+ "notifications.title.TROPHY_SUBMITTED": "",
+ "notifications.text.TROPHY_SUBMITTED": "",
+ "notifications.title.TROPHY_SUBMISSION_ACCEPTED": "",
+ "notifications.text.TROPHY_SUBMISSION_ACCEPTED": "",
+ "notifications.title.TROPHY_SUBMISSION_DECLINED": "",
+ "notifications.text.TROPHY_SUBMISSION_DECLINED": "",
"notifications.title.PLUS_VOTING_STARTED": "Plus serveur votings ouvert !",
"notifications.text.PLUS_VOTING_STARTED": "Plus Serveur votings de la saison {{seasonNth}} sont ouvert",
"notifications.title.PLUS_SUGGESTION_ADDED": "Plus Suggest Ajouté",
@@ -191,6 +199,8 @@
"forms.userSearch.noResults": "Aucun utilisateur trouvé",
"forms.tournamentSearch.placeholder": "",
"forms.tournamentSearch.noResults": "",
+ "forms.organizationSearch.placeholder": "",
+ "forms.organizationSearch.noResults": "",
"forms.teamSearch.placeholder": "",
"forms.teamSearch.noResults": "",
"forms.weaponSearch.placeholder": "",
@@ -382,6 +392,9 @@
"badges.selector.none": "Aucun badges est sélectionné",
"badges.selector.select": "Selectionner un badge pour l'ajouter",
"badges.selector.noneAvailable": "",
+ "trophies.selector.none": "",
+ "trophies.selector.select": "",
+ "trophies.selector.noneAvailable": "",
"api.title": "",
"api.description": "",
"api.noAccess": "",
diff --git a/locales/fr-EU/forms.json b/locales/fr-EU/forms.json
index d43685adc..e592f5ead 100644
--- a/locales/fr-EU/forms.json
+++ b/locales/fr-EU/forms.json
@@ -420,5 +420,15 @@
"placeholders.friendCode": "",
"unsavedChanges.title": "",
"unsavedChanges.body": "",
- "unsavedChanges.discard": ""
+ "unsavedChanges.discard": "",
+ "labels.trophyName": "",
+ "labels.trophyModel": "",
+ "labels.trophyOrganization": "",
+ "labels.trophyManager": "",
+ "labels.trophyInformation": "",
+ "labels.profileFavoriteTrophies": "",
+ "labels.profileHiddenTrophies": "",
+ "bottomTexts.trophyModel": "",
+ "errors.trophyNameTaken": "",
+ "errors.trophyWithBadges": ""
}
diff --git a/locales/fr-EU/org.json b/locales/fr-EU/org.json
index efc04bb06..f7f224059 100644
--- a/locales/fr-EU/org.json
+++ b/locales/fr-EU/org.json
@@ -20,6 +20,7 @@
"edit.form.series.seriesName.title": "Nom de la série ",
"edit.form.series.showLeaderboard.title": "Montrer le leaderboard",
"edit.form.badges.title": "Badges",
+ "edit.form.rewards.title": "",
"edit.form.errors.noUnadmin": "Vous ne pouvez pas vous supprimer en tant qu'administrateur",
"banned.title": "",
"banned.empty": "",
diff --git a/locales/fr-EU/tournament.json b/locales/fr-EU/tournament.json
index 47bfad31b..bc11e3d1e 100644
--- a/locales/fr-EU/tournament.json
+++ b/locales/fr-EU/tournament.json
@@ -136,6 +136,8 @@
"actions.finalize.error.BADGE_NOT_ASSIGNED": "",
"actions.finalize.error.DUPLICATE_TOURNAMENT_TEAM_ID": "",
"actions.finalize.error.BADGE_NOT_FOUND": "",
+ "actions.finalize.error.TROPHY_NOT_FOUND": "",
+ "actions.finalize.error.TROPHY_NOT_ASSIGNED": "",
"actions.finalize.assignBadgesLater": "",
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
diff --git a/locales/fr-EU/trophies.json b/locales/fr-EU/trophies.json
new file mode 100644
index 000000000..7c93cecb8
--- /dev/null
+++ b/locales/fr-EU/trophies.json
@@ -0,0 +1,67 @@
+{
+ "title": "",
+ "lookingForBadges": "",
+ "viewBadges": "",
+ "details.createdBy": "",
+ "details.managedBy": "",
+ "details.organization": "",
+ "details.owners": "",
+ "details.noOwners": "",
+ "details.tournamentHistory": "",
+ "details.noTournamentHistory": "",
+ "details.upcoming": "",
+ "display.viewTrophyPage": "",
+ "special.supporter.description": "",
+ "special.xp.description": "",
+ "new.form.limitReached": "",
+ "new.form.preview.light": "",
+ "new.form.preview.dark": "",
+ "new.specs.required": "",
+ "new.specs.recommended": "",
+ "new.specs.cameraTarget": "",
+ "new.specs.centered": "",
+ "new.specs.zoom": "",
+ "new.specs.angles": "",
+ "new.specs.background": "",
+ "new.specs.drawCalls": "",
+ "new.specs.polys": "",
+ "new.specs.effects": "",
+ "new.specs.currentValue": "",
+ "new.specs.stats.drawCalls": "",
+ "new.specs.stats.polys": "",
+ "new.specs.stats.effects": "",
+ "new.specs.stats.cameraTargetOff": "",
+ "new.specs.stats.backgroundNotAlpha": "",
+ "new.terms.title": "",
+ "new.terms.intro": "",
+ "new.terms.trustedOrg": "",
+ "new.terms.oneOff.title": "",
+ "new.terms.oneOff.pastTournaments": "",
+ "new.terms.oneOff.projectedTeams": "",
+ "new.terms.oneOff.signedUpTeams": "",
+ "new.terms.series.title": "",
+ "new.terms.series.consistentTeams": "",
+ "new.terms.disclaimer": "",
+ "new.terms.preApproval": "",
+ "new.terms.agree": "",
+ "new.tabs.upload": "",
+ "new.tabs.update": "",
+ "new.tabs.pending": "",
+ "new.tabs.reviewed": "",
+ "new.update.selectLabel": "",
+ "new.update.searchPlaceholder": "",
+ "new.update.editing": "",
+ "new.update.before": "",
+ "new.update.after": "",
+ "new.pending.empty": "",
+ "new.pending.notFromManager": "",
+ "new.pending.approve": "",
+ "new.pending.approved": "",
+ "new.pending.approvalProgress": "",
+ "new.pending.decline": "",
+ "new.pending.declineHeading": "",
+ "new.pending.declineReason": "",
+ "new.pending.declined": "",
+ "new.pending.declinedBy": "",
+ "new.reviewed.empty": ""
+}
diff --git a/locales/fr-EU/user.json b/locales/fr-EU/user.json
index ea45ddbd6..6ab57bff4 100644
--- a/locales/fr-EU/user.json
+++ b/locales/fr-EU/user.json
@@ -3,6 +3,7 @@
"ign.short": "PEJ",
"widget.bio": "",
"widget.bio-md": "",
+ "widget.trophies-owned": "",
"widget.badges-owned": "",
"widget.badges-authored": "",
"widget.badges-managed": "",
@@ -60,6 +61,7 @@
"widgets.maxReached": "",
"widgets.search": "",
"widgets.category.misc": "",
+ "widgets.category.trophies": "",
"widgets.category.badges": "",
"widgets.category.teams": "",
"widgets.category.sendouq": "",
@@ -72,6 +74,7 @@
"widgets.category.game-badges": "",
"widgets.description.bio": "",
"widgets.description.bio-md": "",
+ "widgets.description.trophies-owned": "",
"widgets.description.badges-owned": "",
"widgets.description.badges-authored": "",
"widgets.description.badges-managed": "",
diff --git a/locales/he/calendar.json b/locales/he/calendar.json
index 469b6f955..bb9555995 100644
--- a/locales/he/calendar.json
+++ b/locales/he/calendar.json
@@ -22,6 +22,8 @@
"forms.tags.placeholder": "בחירת תג",
"forms.badges": "פרסי תגים",
"forms.badges.placeholder": "בחירת פרס תג",
+ "forms.trophy": "",
+ "forms.trophy.placeholder": "",
"forms.mapPool": "מאגר מפות",
"forms.reportResultsHeader": "דיווח תוצאות עבור {{eventName}}",
"forms.reportResultsInfo": "אתם בוחרים כמה תוצאות לדווח. זה יכול להיות רק הצוות המנצח, הטופ מ-3 או כל מה שתחליטו.",
diff --git a/locales/he/common.json b/locales/he/common.json
index 02c4b56cb..9692ee650 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -2,6 +2,7 @@
"pages.admin": "מנהל",
"pages.api": "API",
"pages.articles": "כתבות",
+ "pages.trophies": "",
"pages.badges": "תגים",
"pages.plus": "שרת פלוס",
"pages.contributors": "תורמים",
@@ -49,6 +50,7 @@
"header.adder.art": "",
"header.adder.vod": "",
"header.adder.plusSuggestion": "",
+ "header.adder.trophy": "",
"notifications.title": "",
"notifications.empty": "",
"notifications.seeAll": "",
@@ -70,6 +72,12 @@
"notifications.text.BADGE_ADDED": "",
"notifications.title.BADGE_MANAGER_ADDED": "",
"notifications.text.BADGE_MANAGER_ADDED": "",
+ "notifications.title.TROPHY_SUBMITTED": "",
+ "notifications.text.TROPHY_SUBMITTED": "",
+ "notifications.title.TROPHY_SUBMISSION_ACCEPTED": "",
+ "notifications.text.TROPHY_SUBMISSION_ACCEPTED": "",
+ "notifications.title.TROPHY_SUBMISSION_DECLINED": "",
+ "notifications.text.TROPHY_SUBMISSION_DECLINED": "",
"notifications.title.PLUS_VOTING_STARTED": "",
"notifications.text.PLUS_VOTING_STARTED": "",
"notifications.title.PLUS_SUGGESTION_ADDED": "",
@@ -191,6 +199,8 @@
"forms.userSearch.noResults": "",
"forms.tournamentSearch.placeholder": "",
"forms.tournamentSearch.noResults": "",
+ "forms.organizationSearch.placeholder": "",
+ "forms.organizationSearch.noResults": "",
"forms.teamSearch.placeholder": "",
"forms.teamSearch.noResults": "",
"forms.weaponSearch.placeholder": "",
@@ -382,6 +392,9 @@
"badges.selector.none": "",
"badges.selector.select": "",
"badges.selector.noneAvailable": "",
+ "trophies.selector.none": "",
+ "trophies.selector.select": "",
+ "trophies.selector.noneAvailable": "",
"api.title": "",
"api.description": "",
"api.noAccess": "",
diff --git a/locales/he/forms.json b/locales/he/forms.json
index 93cb8c2fb..a23c1d5d9 100644
--- a/locales/he/forms.json
+++ b/locales/he/forms.json
@@ -420,5 +420,15 @@
"placeholders.friendCode": "",
"unsavedChanges.title": "",
"unsavedChanges.body": "",
- "unsavedChanges.discard": ""
+ "unsavedChanges.discard": "",
+ "labels.trophyName": "",
+ "labels.trophyModel": "",
+ "labels.trophyOrganization": "",
+ "labels.trophyManager": "",
+ "labels.trophyInformation": "",
+ "labels.profileFavoriteTrophies": "",
+ "labels.profileHiddenTrophies": "",
+ "bottomTexts.trophyModel": "",
+ "errors.trophyNameTaken": "",
+ "errors.trophyWithBadges": ""
}
diff --git a/locales/he/org.json b/locales/he/org.json
index c4a8fa9df..95e15b4ce 100644
--- a/locales/he/org.json
+++ b/locales/he/org.json
@@ -20,6 +20,7 @@
"edit.form.series.seriesName.title": "",
"edit.form.series.showLeaderboard.title": "",
"edit.form.badges.title": "",
+ "edit.form.rewards.title": "",
"edit.form.errors.noUnadmin": "",
"banned.title": "",
"banned.empty": "",
diff --git a/locales/he/tournament.json b/locales/he/tournament.json
index 95bc25713..808cd9a5b 100644
--- a/locales/he/tournament.json
+++ b/locales/he/tournament.json
@@ -136,6 +136,8 @@
"actions.finalize.error.BADGE_NOT_ASSIGNED": "",
"actions.finalize.error.DUPLICATE_TOURNAMENT_TEAM_ID": "",
"actions.finalize.error.BADGE_NOT_FOUND": "",
+ "actions.finalize.error.TROPHY_NOT_FOUND": "",
+ "actions.finalize.error.TROPHY_NOT_ASSIGNED": "",
"actions.finalize.assignBadgesLater": "",
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
diff --git a/locales/he/trophies.json b/locales/he/trophies.json
new file mode 100644
index 000000000..7c93cecb8
--- /dev/null
+++ b/locales/he/trophies.json
@@ -0,0 +1,67 @@
+{
+ "title": "",
+ "lookingForBadges": "",
+ "viewBadges": "",
+ "details.createdBy": "",
+ "details.managedBy": "",
+ "details.organization": "",
+ "details.owners": "",
+ "details.noOwners": "",
+ "details.tournamentHistory": "",
+ "details.noTournamentHistory": "",
+ "details.upcoming": "",
+ "display.viewTrophyPage": "",
+ "special.supporter.description": "",
+ "special.xp.description": "",
+ "new.form.limitReached": "",
+ "new.form.preview.light": "",
+ "new.form.preview.dark": "",
+ "new.specs.required": "",
+ "new.specs.recommended": "",
+ "new.specs.cameraTarget": "",
+ "new.specs.centered": "",
+ "new.specs.zoom": "",
+ "new.specs.angles": "",
+ "new.specs.background": "",
+ "new.specs.drawCalls": "",
+ "new.specs.polys": "",
+ "new.specs.effects": "",
+ "new.specs.currentValue": "",
+ "new.specs.stats.drawCalls": "",
+ "new.specs.stats.polys": "",
+ "new.specs.stats.effects": "",
+ "new.specs.stats.cameraTargetOff": "",
+ "new.specs.stats.backgroundNotAlpha": "",
+ "new.terms.title": "",
+ "new.terms.intro": "",
+ "new.terms.trustedOrg": "",
+ "new.terms.oneOff.title": "",
+ "new.terms.oneOff.pastTournaments": "",
+ "new.terms.oneOff.projectedTeams": "",
+ "new.terms.oneOff.signedUpTeams": "",
+ "new.terms.series.title": "",
+ "new.terms.series.consistentTeams": "",
+ "new.terms.disclaimer": "",
+ "new.terms.preApproval": "",
+ "new.terms.agree": "",
+ "new.tabs.upload": "",
+ "new.tabs.update": "",
+ "new.tabs.pending": "",
+ "new.tabs.reviewed": "",
+ "new.update.selectLabel": "",
+ "new.update.searchPlaceholder": "",
+ "new.update.editing": "",
+ "new.update.before": "",
+ "new.update.after": "",
+ "new.pending.empty": "",
+ "new.pending.notFromManager": "",
+ "new.pending.approve": "",
+ "new.pending.approved": "",
+ "new.pending.approvalProgress": "",
+ "new.pending.decline": "",
+ "new.pending.declineHeading": "",
+ "new.pending.declineReason": "",
+ "new.pending.declined": "",
+ "new.pending.declinedBy": "",
+ "new.reviewed.empty": ""
+}
diff --git a/locales/he/user.json b/locales/he/user.json
index f52c8d522..592912939 100644
--- a/locales/he/user.json
+++ b/locales/he/user.json
@@ -3,6 +3,7 @@
"ign.short": "IGN",
"widget.bio": "",
"widget.bio-md": "",
+ "widget.trophies-owned": "",
"widget.badges-owned": "",
"widget.badges-authored": "",
"widget.badges-managed": "",
@@ -60,6 +61,7 @@
"widgets.maxReached": "",
"widgets.search": "",
"widgets.category.misc": "",
+ "widgets.category.trophies": "",
"widgets.category.badges": "",
"widgets.category.teams": "",
"widgets.category.sendouq": "",
@@ -72,6 +74,7 @@
"widgets.category.game-badges": "",
"widgets.description.bio": "",
"widgets.description.bio-md": "",
+ "widgets.description.trophies-owned": "",
"widgets.description.badges-owned": "",
"widgets.description.badges-authored": "",
"widgets.description.badges-managed": "",
diff --git a/locales/it/calendar.json b/locales/it/calendar.json
index 947670537..42bf791f7 100644
--- a/locales/it/calendar.json
+++ b/locales/it/calendar.json
@@ -22,6 +22,8 @@
"forms.tags.placeholder": "Scegli un tag",
"forms.badges": "Medaglie in palio",
"forms.badges.placeholder": "Scegli una medaglia come premio",
+ "forms.trophy": "",
+ "forms.trophy.placeholder": "",
"forms.mapPool": "Pool di scenari",
"forms.reportResultsHeader": "Reporting results for {{eventName}}",
"forms.reportResultsInfo": "Puoi scegliere quanti risultati vuoi riportare, se fare solo la squadra vincitrice, la top 3 o un'altra combinazione.",
diff --git a/locales/it/common.json b/locales/it/common.json
index 3c90d7643..537c5afb5 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -2,6 +2,7 @@
"pages.admin": "Admin",
"pages.api": "",
"pages.articles": "Articoli",
+ "pages.trophies": "",
"pages.badges": "Medaglie",
"pages.plus": "Server Plus",
"pages.contributors": "Contributori",
@@ -49,6 +50,7 @@
"header.adder.art": "Art",
"header.adder.vod": "VoD",
"header.adder.plusSuggestion": "Suggerimento Plus",
+ "header.adder.trophy": "",
"notifications.title": "Notifiche",
"notifications.empty": "Nessuna notifica, controlla più tardi",
"notifications.seeAll": "Vedi tutte",
@@ -70,6 +72,12 @@
"notifications.text.BADGE_ADDED": "Nuova medaglia ({{badgeName}})",
"notifications.title.BADGE_MANAGER_ADDED": "Ottenuti diritti di gestione",
"notifications.text.BADGE_MANAGER_ADDED": "Hai ottenuto diritti di gestione per la medaglia {{badgeName}}",
+ "notifications.title.TROPHY_SUBMITTED": "",
+ "notifications.text.TROPHY_SUBMITTED": "",
+ "notifications.title.TROPHY_SUBMISSION_ACCEPTED": "",
+ "notifications.text.TROPHY_SUBMISSION_ACCEPTED": "",
+ "notifications.title.TROPHY_SUBMISSION_DECLINED": "",
+ "notifications.text.TROPHY_SUBMISSION_DECLINED": "",
"notifications.title.PLUS_VOTING_STARTED": "Votazione Plus iniziata",
"notifications.text.PLUS_VOTING_STARTED": "Votazione Plus della stagione {{seasonNth}} iniziata",
"notifications.title.PLUS_SUGGESTION_ADDED": "Suggeriti al Server Plus",
@@ -191,6 +199,8 @@
"forms.userSearch.noResults": "",
"forms.tournamentSearch.placeholder": "",
"forms.tournamentSearch.noResults": "",
+ "forms.organizationSearch.placeholder": "",
+ "forms.organizationSearch.noResults": "",
"forms.teamSearch.placeholder": "",
"forms.teamSearch.noResults": "",
"forms.weaponSearch.placeholder": "",
@@ -382,6 +392,9 @@
"badges.selector.none": "Nessuna medaglia selezionata",
"badges.selector.select": "Seleziona medaglia da aggiungere",
"badges.selector.noneAvailable": "",
+ "trophies.selector.none": "",
+ "trophies.selector.select": "",
+ "trophies.selector.noneAvailable": "",
"api.title": "",
"api.description": "",
"api.noAccess": "",
diff --git a/locales/it/forms.json b/locales/it/forms.json
index f49b87dc9..b84efc435 100644
--- a/locales/it/forms.json
+++ b/locales/it/forms.json
@@ -420,5 +420,15 @@
"placeholders.friendCode": "",
"unsavedChanges.title": "",
"unsavedChanges.body": "",
- "unsavedChanges.discard": ""
+ "unsavedChanges.discard": "",
+ "labels.trophyName": "",
+ "labels.trophyModel": "",
+ "labels.trophyOrganization": "",
+ "labels.trophyManager": "",
+ "labels.trophyInformation": "",
+ "labels.profileFavoriteTrophies": "",
+ "labels.profileHiddenTrophies": "",
+ "bottomTexts.trophyModel": "",
+ "errors.trophyNameTaken": "",
+ "errors.trophyWithBadges": ""
}
diff --git a/locales/it/org.json b/locales/it/org.json
index 2b427a26f..9c1e1480d 100644
--- a/locales/it/org.json
+++ b/locales/it/org.json
@@ -20,6 +20,7 @@
"edit.form.series.seriesName.title": "Nome serie",
"edit.form.series.showLeaderboard.title": "Mostra classifica",
"edit.form.badges.title": "Medaglia",
+ "edit.form.rewards.title": "",
"edit.form.errors.noUnadmin": "Non puoi rimuoverti dal ruolo di admin",
"banned.title": "",
"banned.empty": "",
diff --git a/locales/it/tournament.json b/locales/it/tournament.json
index eca48fac0..8e5d1b208 100644
--- a/locales/it/tournament.json
+++ b/locales/it/tournament.json
@@ -136,6 +136,8 @@
"actions.finalize.error.BADGE_NOT_ASSIGNED": "",
"actions.finalize.error.DUPLICATE_TOURNAMENT_TEAM_ID": "",
"actions.finalize.error.BADGE_NOT_FOUND": "",
+ "actions.finalize.error.TROPHY_NOT_FOUND": "",
+ "actions.finalize.error.TROPHY_NOT_ASSIGNED": "",
"actions.finalize.assignBadgesLater": "",
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
diff --git a/locales/it/trophies.json b/locales/it/trophies.json
new file mode 100644
index 000000000..7c93cecb8
--- /dev/null
+++ b/locales/it/trophies.json
@@ -0,0 +1,67 @@
+{
+ "title": "",
+ "lookingForBadges": "",
+ "viewBadges": "",
+ "details.createdBy": "",
+ "details.managedBy": "",
+ "details.organization": "",
+ "details.owners": "",
+ "details.noOwners": "",
+ "details.tournamentHistory": "",
+ "details.noTournamentHistory": "",
+ "details.upcoming": "",
+ "display.viewTrophyPage": "",
+ "special.supporter.description": "",
+ "special.xp.description": "",
+ "new.form.limitReached": "",
+ "new.form.preview.light": "",
+ "new.form.preview.dark": "",
+ "new.specs.required": "",
+ "new.specs.recommended": "",
+ "new.specs.cameraTarget": "",
+ "new.specs.centered": "",
+ "new.specs.zoom": "",
+ "new.specs.angles": "",
+ "new.specs.background": "",
+ "new.specs.drawCalls": "",
+ "new.specs.polys": "",
+ "new.specs.effects": "",
+ "new.specs.currentValue": "",
+ "new.specs.stats.drawCalls": "",
+ "new.specs.stats.polys": "",
+ "new.specs.stats.effects": "",
+ "new.specs.stats.cameraTargetOff": "",
+ "new.specs.stats.backgroundNotAlpha": "",
+ "new.terms.title": "",
+ "new.terms.intro": "",
+ "new.terms.trustedOrg": "",
+ "new.terms.oneOff.title": "",
+ "new.terms.oneOff.pastTournaments": "",
+ "new.terms.oneOff.projectedTeams": "",
+ "new.terms.oneOff.signedUpTeams": "",
+ "new.terms.series.title": "",
+ "new.terms.series.consistentTeams": "",
+ "new.terms.disclaimer": "",
+ "new.terms.preApproval": "",
+ "new.terms.agree": "",
+ "new.tabs.upload": "",
+ "new.tabs.update": "",
+ "new.tabs.pending": "",
+ "new.tabs.reviewed": "",
+ "new.update.selectLabel": "",
+ "new.update.searchPlaceholder": "",
+ "new.update.editing": "",
+ "new.update.before": "",
+ "new.update.after": "",
+ "new.pending.empty": "",
+ "new.pending.notFromManager": "",
+ "new.pending.approve": "",
+ "new.pending.approved": "",
+ "new.pending.approvalProgress": "",
+ "new.pending.decline": "",
+ "new.pending.declineHeading": "",
+ "new.pending.declineReason": "",
+ "new.pending.declined": "",
+ "new.pending.declinedBy": "",
+ "new.reviewed.empty": ""
+}
diff --git a/locales/it/user.json b/locales/it/user.json
index 7ae4f668d..0514319d9 100644
--- a/locales/it/user.json
+++ b/locales/it/user.json
@@ -3,6 +3,7 @@
"ign.short": "IGN",
"widget.bio": "",
"widget.bio-md": "",
+ "widget.trophies-owned": "",
"widget.badges-owned": "",
"widget.badges-authored": "",
"widget.badges-managed": "",
@@ -60,6 +61,7 @@
"widgets.maxReached": "",
"widgets.search": "",
"widgets.category.misc": "",
+ "widgets.category.trophies": "",
"widgets.category.badges": "",
"widgets.category.teams": "",
"widgets.category.sendouq": "",
@@ -72,6 +74,7 @@
"widgets.category.game-badges": "",
"widgets.description.bio": "",
"widgets.description.bio-md": "",
+ "widgets.description.trophies-owned": "",
"widgets.description.badges-owned": "",
"widgets.description.badges-authored": "",
"widgets.description.badges-managed": "",
diff --git a/locales/ja/calendar.json b/locales/ja/calendar.json
index f6e8b0a2d..8a4576b20 100644
--- a/locales/ja/calendar.json
+++ b/locales/ja/calendar.json
@@ -18,6 +18,8 @@
"forms.tags.placeholder": "タグを選択",
"forms.badges": "優勝賞品のバッジ",
"forms.badges.placeholder": "バッジを選択する",
+ "forms.trophy": "",
+ "forms.trophy.placeholder": "",
"forms.mapPool": "選択可能なステージ",
"forms.reportResultsHeader": "{{eventName}} の結果報告",
"forms.reportResultsInfo": "報告する結果の数を選択します。勝者のみ、トップ3など自由に決定できます。",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 717f86e87..cf8b5f538 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -2,6 +2,7 @@
"pages.admin": "管理",
"pages.api": "API",
"pages.articles": "記事一覧",
+ "pages.trophies": "",
"pages.badges": "バッジ",
"pages.plus": "プラスサーバー",
"pages.contributors": "貢献者一覧",
@@ -49,6 +50,7 @@
"header.adder.art": "イラスト",
"header.adder.vod": "動画",
"header.adder.plusSuggestion": "プラスサーバー推薦",
+ "header.adder.trophy": "",
"notifications.title": "通知",
"notifications.empty": "通知はありません",
"notifications.seeAll": "通知一覧",
@@ -70,6 +72,12 @@
"notifications.text.BADGE_ADDED": "新バッジ({{badgeName}})が追加されました",
"notifications.title.BADGE_MANAGER_ADDED": "管理者権限が付与されました",
"notifications.text.BADGE_MANAGER_ADDED": "バッジ{{badgeName}}の管理者権限が付与されました",
+ "notifications.title.TROPHY_SUBMITTED": "",
+ "notifications.text.TROPHY_SUBMITTED": "",
+ "notifications.title.TROPHY_SUBMISSION_ACCEPTED": "",
+ "notifications.text.TROPHY_SUBMISSION_ACCEPTED": "",
+ "notifications.title.TROPHY_SUBMISSION_DECLINED": "",
+ "notifications.text.TROPHY_SUBMISSION_DECLINED": "",
"notifications.title.PLUS_VOTING_STARTED": "プラスサーバー投票が開始しました",
"notifications.text.PLUS_VOTING_STARTED": "シーズン{{seasonNth}}のプラスサーバー投票が開始されました",
"notifications.title.PLUS_SUGGESTION_ADDED": "プラスサーバーに推薦されました",
@@ -191,6 +199,8 @@
"forms.userSearch.noResults": "一致するユーザーが見つかりませんでした",
"forms.tournamentSearch.placeholder": "大会名で探す...",
"forms.tournamentSearch.noResults": "一致する大会が見つかりませんでした",
+ "forms.organizationSearch.placeholder": "",
+ "forms.organizationSearch.noResults": "",
"forms.teamSearch.placeholder": "チーム名で探す...",
"forms.teamSearch.noResults": "一致するチームが見つかりませんでした",
"forms.weaponSearch.placeholder": "ブキを選択してください",
@@ -382,6 +392,9 @@
"badges.selector.none": "バッジが選択されていません",
"badges.selector.select": "追加するバッジを選んでください",
"badges.selector.noneAvailable": "選択できるバッジがありません。バッジを追加するには、組織のバッジ管理者権限が必要です。",
+ "trophies.selector.none": "",
+ "trophies.selector.select": "",
+ "trophies.selector.noneAvailable": "",
"api.title": "API アクセス",
"api.description": "sendou.ink の API をアクセスするには、API トークンを作成してください。詳しくは<1>API documentation1>をご覧ください。",
"api.noAccess": "API にアクセスできません。サポーター、スタッフ、大会運営者または配信者のみアクセスできます。",
diff --git a/locales/ja/forms.json b/locales/ja/forms.json
index c5805f514..f7c2c9bf7 100644
--- a/locales/ja/forms.json
+++ b/locales/ja/forms.json
@@ -420,5 +420,15 @@
"placeholders.friendCode": "",
"unsavedChanges.title": "",
"unsavedChanges.body": "",
- "unsavedChanges.discard": ""
+ "unsavedChanges.discard": "",
+ "labels.trophyName": "",
+ "labels.trophyModel": "",
+ "labels.trophyOrganization": "",
+ "labels.trophyManager": "",
+ "labels.trophyInformation": "",
+ "labels.profileFavoriteTrophies": "",
+ "labels.profileHiddenTrophies": "",
+ "bottomTexts.trophyModel": "",
+ "errors.trophyNameTaken": "",
+ "errors.trophyWithBadges": ""
}
diff --git a/locales/ja/org.json b/locales/ja/org.json
index c4a8fa9df..95e15b4ce 100644
--- a/locales/ja/org.json
+++ b/locales/ja/org.json
@@ -20,6 +20,7 @@
"edit.form.series.seriesName.title": "",
"edit.form.series.showLeaderboard.title": "",
"edit.form.badges.title": "",
+ "edit.form.rewards.title": "",
"edit.form.errors.noUnadmin": "",
"banned.title": "",
"banned.empty": "",
diff --git a/locales/ja/tournament.json b/locales/ja/tournament.json
index 62ef547fc..1f750d1e5 100644
--- a/locales/ja/tournament.json
+++ b/locales/ja/tournament.json
@@ -130,6 +130,8 @@
"actions.finalize.error.BADGE_NOT_ASSIGNED": "",
"actions.finalize.error.DUPLICATE_TOURNAMENT_TEAM_ID": "",
"actions.finalize.error.BADGE_NOT_FOUND": "",
+ "actions.finalize.error.TROPHY_NOT_FOUND": "",
+ "actions.finalize.error.TROPHY_NOT_ASSIGNED": "",
"actions.finalize.assignBadgesLater": "",
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
diff --git a/locales/ja/trophies.json b/locales/ja/trophies.json
new file mode 100644
index 000000000..7c93cecb8
--- /dev/null
+++ b/locales/ja/trophies.json
@@ -0,0 +1,67 @@
+{
+ "title": "",
+ "lookingForBadges": "",
+ "viewBadges": "",
+ "details.createdBy": "",
+ "details.managedBy": "",
+ "details.organization": "",
+ "details.owners": "",
+ "details.noOwners": "",
+ "details.tournamentHistory": "",
+ "details.noTournamentHistory": "",
+ "details.upcoming": "",
+ "display.viewTrophyPage": "",
+ "special.supporter.description": "",
+ "special.xp.description": "",
+ "new.form.limitReached": "",
+ "new.form.preview.light": "",
+ "new.form.preview.dark": "",
+ "new.specs.required": "",
+ "new.specs.recommended": "",
+ "new.specs.cameraTarget": "",
+ "new.specs.centered": "",
+ "new.specs.zoom": "",
+ "new.specs.angles": "",
+ "new.specs.background": "",
+ "new.specs.drawCalls": "",
+ "new.specs.polys": "",
+ "new.specs.effects": "",
+ "new.specs.currentValue": "",
+ "new.specs.stats.drawCalls": "",
+ "new.specs.stats.polys": "",
+ "new.specs.stats.effects": "",
+ "new.specs.stats.cameraTargetOff": "",
+ "new.specs.stats.backgroundNotAlpha": "",
+ "new.terms.title": "",
+ "new.terms.intro": "",
+ "new.terms.trustedOrg": "",
+ "new.terms.oneOff.title": "",
+ "new.terms.oneOff.pastTournaments": "",
+ "new.terms.oneOff.projectedTeams": "",
+ "new.terms.oneOff.signedUpTeams": "",
+ "new.terms.series.title": "",
+ "new.terms.series.consistentTeams": "",
+ "new.terms.disclaimer": "",
+ "new.terms.preApproval": "",
+ "new.terms.agree": "",
+ "new.tabs.upload": "",
+ "new.tabs.update": "",
+ "new.tabs.pending": "",
+ "new.tabs.reviewed": "",
+ "new.update.selectLabel": "",
+ "new.update.searchPlaceholder": "",
+ "new.update.editing": "",
+ "new.update.before": "",
+ "new.update.after": "",
+ "new.pending.empty": "",
+ "new.pending.notFromManager": "",
+ "new.pending.approve": "",
+ "new.pending.approved": "",
+ "new.pending.approvalProgress": "",
+ "new.pending.decline": "",
+ "new.pending.declineHeading": "",
+ "new.pending.declineReason": "",
+ "new.pending.declined": "",
+ "new.pending.declinedBy": "",
+ "new.reviewed.empty": ""
+}
diff --git a/locales/ja/user.json b/locales/ja/user.json
index 58f8efe1b..f6258057a 100644
--- a/locales/ja/user.json
+++ b/locales/ja/user.json
@@ -3,6 +3,7 @@
"ign.short": "ゲーム中の名前",
"widget.bio": "",
"widget.bio-md": "",
+ "widget.trophies-owned": "",
"widget.badges-owned": "",
"widget.badges-authored": "",
"widget.badges-managed": "",
@@ -60,6 +61,7 @@
"widgets.maxReached": "",
"widgets.search": "",
"widgets.category.misc": "",
+ "widgets.category.trophies": "",
"widgets.category.badges": "",
"widgets.category.teams": "",
"widgets.category.sendouq": "",
@@ -72,6 +74,7 @@
"widgets.category.game-badges": "",
"widgets.description.bio": "",
"widgets.description.bio-md": "",
+ "widgets.description.trophies-owned": "",
"widgets.description.badges-owned": "",
"widgets.description.badges-authored": "",
"widgets.description.badges-managed": "",
diff --git a/locales/ko/calendar.json b/locales/ko/calendar.json
index 929acca6c..fe8ba6771 100644
--- a/locales/ko/calendar.json
+++ b/locales/ko/calendar.json
@@ -16,6 +16,8 @@
"forms.tags.placeholder": "태그를 선택하세요",
"forms.badges": "배지 상품",
"forms.badges.placeholder": "배지 상품을 선택하세요",
+ "forms.trophy": "",
+ "forms.trophy.placeholder": "",
"forms.mapPool": "",
"forms.reportResultsHeader": "{{eventName}}의 결과 보고",
"forms.reportResultsInfo": "얼마나 많은 결과를 보고할지 선택할 수 있습니다.우승팀만일 수도 있고 탑3 또는 원하시는대로.",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 35e21f204..c0c667bcf 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -2,6 +2,7 @@
"pages.admin": "관리자",
"pages.api": "",
"pages.articles": "게시물",
+ "pages.trophies": "",
"pages.badges": "배지",
"pages.plus": "Plus Server",
"pages.contributors": "기여자",
@@ -49,6 +50,7 @@
"header.adder.art": "",
"header.adder.vod": "",
"header.adder.plusSuggestion": "",
+ "header.adder.trophy": "",
"notifications.title": "",
"notifications.empty": "",
"notifications.seeAll": "",
@@ -70,6 +72,12 @@
"notifications.text.BADGE_ADDED": "",
"notifications.title.BADGE_MANAGER_ADDED": "",
"notifications.text.BADGE_MANAGER_ADDED": "",
+ "notifications.title.TROPHY_SUBMITTED": "",
+ "notifications.text.TROPHY_SUBMITTED": "",
+ "notifications.title.TROPHY_SUBMISSION_ACCEPTED": "",
+ "notifications.text.TROPHY_SUBMISSION_ACCEPTED": "",
+ "notifications.title.TROPHY_SUBMISSION_DECLINED": "",
+ "notifications.text.TROPHY_SUBMISSION_DECLINED": "",
"notifications.title.PLUS_VOTING_STARTED": "",
"notifications.text.PLUS_VOTING_STARTED": "",
"notifications.title.PLUS_SUGGESTION_ADDED": "",
@@ -191,6 +199,8 @@
"forms.userSearch.noResults": "",
"forms.tournamentSearch.placeholder": "",
"forms.tournamentSearch.noResults": "",
+ "forms.organizationSearch.placeholder": "",
+ "forms.organizationSearch.noResults": "",
"forms.teamSearch.placeholder": "",
"forms.teamSearch.noResults": "",
"forms.weaponSearch.placeholder": "",
@@ -382,6 +392,9 @@
"badges.selector.none": "",
"badges.selector.select": "",
"badges.selector.noneAvailable": "",
+ "trophies.selector.none": "",
+ "trophies.selector.select": "",
+ "trophies.selector.noneAvailable": "",
"api.title": "",
"api.description": "",
"api.noAccess": "",
diff --git a/locales/ko/forms.json b/locales/ko/forms.json
index 1b9b16c7f..7eae82f4c 100644
--- a/locales/ko/forms.json
+++ b/locales/ko/forms.json
@@ -420,5 +420,15 @@
"placeholders.friendCode": "",
"unsavedChanges.title": "",
"unsavedChanges.body": "",
- "unsavedChanges.discard": ""
+ "unsavedChanges.discard": "",
+ "labels.trophyName": "",
+ "labels.trophyModel": "",
+ "labels.trophyOrganization": "",
+ "labels.trophyManager": "",
+ "labels.trophyInformation": "",
+ "labels.profileFavoriteTrophies": "",
+ "labels.profileHiddenTrophies": "",
+ "bottomTexts.trophyModel": "",
+ "errors.trophyNameTaken": "",
+ "errors.trophyWithBadges": ""
}
diff --git a/locales/ko/org.json b/locales/ko/org.json
index c4a8fa9df..95e15b4ce 100644
--- a/locales/ko/org.json
+++ b/locales/ko/org.json
@@ -20,6 +20,7 @@
"edit.form.series.seriesName.title": "",
"edit.form.series.showLeaderboard.title": "",
"edit.form.badges.title": "",
+ "edit.form.rewards.title": "",
"edit.form.errors.noUnadmin": "",
"banned.title": "",
"banned.empty": "",
diff --git a/locales/ko/tournament.json b/locales/ko/tournament.json
index 0a3c9c316..87919d3fb 100644
--- a/locales/ko/tournament.json
+++ b/locales/ko/tournament.json
@@ -130,6 +130,8 @@
"actions.finalize.error.BADGE_NOT_ASSIGNED": "",
"actions.finalize.error.DUPLICATE_TOURNAMENT_TEAM_ID": "",
"actions.finalize.error.BADGE_NOT_FOUND": "",
+ "actions.finalize.error.TROPHY_NOT_FOUND": "",
+ "actions.finalize.error.TROPHY_NOT_ASSIGNED": "",
"actions.finalize.assignBadgesLater": "",
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
diff --git a/locales/ko/trophies.json b/locales/ko/trophies.json
new file mode 100644
index 000000000..7c93cecb8
--- /dev/null
+++ b/locales/ko/trophies.json
@@ -0,0 +1,67 @@
+{
+ "title": "",
+ "lookingForBadges": "",
+ "viewBadges": "",
+ "details.createdBy": "",
+ "details.managedBy": "",
+ "details.organization": "",
+ "details.owners": "",
+ "details.noOwners": "",
+ "details.tournamentHistory": "",
+ "details.noTournamentHistory": "",
+ "details.upcoming": "",
+ "display.viewTrophyPage": "",
+ "special.supporter.description": "",
+ "special.xp.description": "",
+ "new.form.limitReached": "",
+ "new.form.preview.light": "",
+ "new.form.preview.dark": "",
+ "new.specs.required": "",
+ "new.specs.recommended": "",
+ "new.specs.cameraTarget": "",
+ "new.specs.centered": "",
+ "new.specs.zoom": "",
+ "new.specs.angles": "",
+ "new.specs.background": "",
+ "new.specs.drawCalls": "",
+ "new.specs.polys": "",
+ "new.specs.effects": "",
+ "new.specs.currentValue": "",
+ "new.specs.stats.drawCalls": "",
+ "new.specs.stats.polys": "",
+ "new.specs.stats.effects": "",
+ "new.specs.stats.cameraTargetOff": "",
+ "new.specs.stats.backgroundNotAlpha": "",
+ "new.terms.title": "",
+ "new.terms.intro": "",
+ "new.terms.trustedOrg": "",
+ "new.terms.oneOff.title": "",
+ "new.terms.oneOff.pastTournaments": "",
+ "new.terms.oneOff.projectedTeams": "",
+ "new.terms.oneOff.signedUpTeams": "",
+ "new.terms.series.title": "",
+ "new.terms.series.consistentTeams": "",
+ "new.terms.disclaimer": "",
+ "new.terms.preApproval": "",
+ "new.terms.agree": "",
+ "new.tabs.upload": "",
+ "new.tabs.update": "",
+ "new.tabs.pending": "",
+ "new.tabs.reviewed": "",
+ "new.update.selectLabel": "",
+ "new.update.searchPlaceholder": "",
+ "new.update.editing": "",
+ "new.update.before": "",
+ "new.update.after": "",
+ "new.pending.empty": "",
+ "new.pending.notFromManager": "",
+ "new.pending.approve": "",
+ "new.pending.approved": "",
+ "new.pending.approvalProgress": "",
+ "new.pending.decline": "",
+ "new.pending.declineHeading": "",
+ "new.pending.declineReason": "",
+ "new.pending.declined": "",
+ "new.pending.declinedBy": "",
+ "new.reviewed.empty": ""
+}
diff --git a/locales/ko/user.json b/locales/ko/user.json
index f23c8dc72..dac5ccd12 100644
--- a/locales/ko/user.json
+++ b/locales/ko/user.json
@@ -3,6 +3,7 @@
"ign.short": "",
"widget.bio": "",
"widget.bio-md": "",
+ "widget.trophies-owned": "",
"widget.badges-owned": "",
"widget.badges-authored": "",
"widget.badges-managed": "",
@@ -60,6 +61,7 @@
"widgets.maxReached": "",
"widgets.search": "",
"widgets.category.misc": "",
+ "widgets.category.trophies": "",
"widgets.category.badges": "",
"widgets.category.teams": "",
"widgets.category.sendouq": "",
@@ -72,6 +74,7 @@
"widgets.category.game-badges": "",
"widgets.description.bio": "",
"widgets.description.bio-md": "",
+ "widgets.description.trophies-owned": "",
"widgets.description.badges-owned": "",
"widgets.description.badges-authored": "",
"widgets.description.badges-managed": "",
diff --git a/locales/nl/calendar.json b/locales/nl/calendar.json
index 7c39f8fb0..f36b8782e 100644
--- a/locales/nl/calendar.json
+++ b/locales/nl/calendar.json
@@ -20,6 +20,8 @@
"forms.tags.placeholder": "Kies een tag",
"forms.badges": "Badge prijzen",
"forms.badges.placeholder": "Kies een badge prijs",
+ "forms.trophy": "",
+ "forms.trophy.placeholder": "",
"forms.mapPool": "Beschikbare levels",
"forms.reportResultsHeader": "Uitslagen doorgeven voor {{eventName}}",
"forms.reportResultsInfo": "Je kunt zelf kiezen hoeveel uitslagen je wilt doorgeven. Het kan bijvoorbeeld alleen het winnende team zijn, maar ook de top 3, etc.",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 7b337a73a..dff60ef00 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -2,6 +2,7 @@
"pages.admin": "Admin",
"pages.api": "",
"pages.articles": "",
+ "pages.trophies": "",
"pages.badges": "Badges",
"pages.plus": "Plus Server",
"pages.contributors": "Bijdragers",
@@ -49,6 +50,7 @@
"header.adder.art": "",
"header.adder.vod": "",
"header.adder.plusSuggestion": "",
+ "header.adder.trophy": "",
"notifications.title": "",
"notifications.empty": "",
"notifications.seeAll": "",
@@ -70,6 +72,12 @@
"notifications.text.BADGE_ADDED": "",
"notifications.title.BADGE_MANAGER_ADDED": "",
"notifications.text.BADGE_MANAGER_ADDED": "",
+ "notifications.title.TROPHY_SUBMITTED": "",
+ "notifications.text.TROPHY_SUBMITTED": "",
+ "notifications.title.TROPHY_SUBMISSION_ACCEPTED": "",
+ "notifications.text.TROPHY_SUBMISSION_ACCEPTED": "",
+ "notifications.title.TROPHY_SUBMISSION_DECLINED": "",
+ "notifications.text.TROPHY_SUBMISSION_DECLINED": "",
"notifications.title.PLUS_VOTING_STARTED": "",
"notifications.text.PLUS_VOTING_STARTED": "",
"notifications.title.PLUS_SUGGESTION_ADDED": "",
@@ -191,6 +199,8 @@
"forms.userSearch.noResults": "",
"forms.tournamentSearch.placeholder": "",
"forms.tournamentSearch.noResults": "",
+ "forms.organizationSearch.placeholder": "",
+ "forms.organizationSearch.noResults": "",
"forms.teamSearch.placeholder": "",
"forms.teamSearch.noResults": "",
"forms.weaponSearch.placeholder": "",
@@ -382,6 +392,9 @@
"badges.selector.none": "",
"badges.selector.select": "",
"badges.selector.noneAvailable": "",
+ "trophies.selector.none": "",
+ "trophies.selector.select": "",
+ "trophies.selector.noneAvailable": "",
"api.title": "",
"api.description": "",
"api.noAccess": "",
diff --git a/locales/nl/forms.json b/locales/nl/forms.json
index 88e9b39f8..38ae46ebf 100644
--- a/locales/nl/forms.json
+++ b/locales/nl/forms.json
@@ -420,5 +420,15 @@
"placeholders.friendCode": "",
"unsavedChanges.title": "",
"unsavedChanges.body": "",
- "unsavedChanges.discard": ""
+ "unsavedChanges.discard": "",
+ "labels.trophyName": "",
+ "labels.trophyModel": "",
+ "labels.trophyOrganization": "",
+ "labels.trophyManager": "",
+ "labels.trophyInformation": "",
+ "labels.profileFavoriteTrophies": "",
+ "labels.profileHiddenTrophies": "",
+ "bottomTexts.trophyModel": "",
+ "errors.trophyNameTaken": "",
+ "errors.trophyWithBadges": ""
}
diff --git a/locales/nl/org.json b/locales/nl/org.json
index c4a8fa9df..95e15b4ce 100644
--- a/locales/nl/org.json
+++ b/locales/nl/org.json
@@ -20,6 +20,7 @@
"edit.form.series.seriesName.title": "",
"edit.form.series.showLeaderboard.title": "",
"edit.form.badges.title": "",
+ "edit.form.rewards.title": "",
"edit.form.errors.noUnadmin": "",
"banned.title": "",
"banned.empty": "",
diff --git a/locales/nl/tournament.json b/locales/nl/tournament.json
index cca0641ba..763e46d1c 100644
--- a/locales/nl/tournament.json
+++ b/locales/nl/tournament.json
@@ -134,6 +134,8 @@
"actions.finalize.error.BADGE_NOT_ASSIGNED": "",
"actions.finalize.error.DUPLICATE_TOURNAMENT_TEAM_ID": "",
"actions.finalize.error.BADGE_NOT_FOUND": "",
+ "actions.finalize.error.TROPHY_NOT_FOUND": "",
+ "actions.finalize.error.TROPHY_NOT_ASSIGNED": "",
"actions.finalize.assignBadgesLater": "",
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
diff --git a/locales/nl/trophies.json b/locales/nl/trophies.json
new file mode 100644
index 000000000..7c93cecb8
--- /dev/null
+++ b/locales/nl/trophies.json
@@ -0,0 +1,67 @@
+{
+ "title": "",
+ "lookingForBadges": "",
+ "viewBadges": "",
+ "details.createdBy": "",
+ "details.managedBy": "",
+ "details.organization": "",
+ "details.owners": "",
+ "details.noOwners": "",
+ "details.tournamentHistory": "",
+ "details.noTournamentHistory": "",
+ "details.upcoming": "",
+ "display.viewTrophyPage": "",
+ "special.supporter.description": "",
+ "special.xp.description": "",
+ "new.form.limitReached": "",
+ "new.form.preview.light": "",
+ "new.form.preview.dark": "",
+ "new.specs.required": "",
+ "new.specs.recommended": "",
+ "new.specs.cameraTarget": "",
+ "new.specs.centered": "",
+ "new.specs.zoom": "",
+ "new.specs.angles": "",
+ "new.specs.background": "",
+ "new.specs.drawCalls": "",
+ "new.specs.polys": "",
+ "new.specs.effects": "",
+ "new.specs.currentValue": "",
+ "new.specs.stats.drawCalls": "",
+ "new.specs.stats.polys": "",
+ "new.specs.stats.effects": "",
+ "new.specs.stats.cameraTargetOff": "",
+ "new.specs.stats.backgroundNotAlpha": "",
+ "new.terms.title": "",
+ "new.terms.intro": "",
+ "new.terms.trustedOrg": "",
+ "new.terms.oneOff.title": "",
+ "new.terms.oneOff.pastTournaments": "",
+ "new.terms.oneOff.projectedTeams": "",
+ "new.terms.oneOff.signedUpTeams": "",
+ "new.terms.series.title": "",
+ "new.terms.series.consistentTeams": "",
+ "new.terms.disclaimer": "",
+ "new.terms.preApproval": "",
+ "new.terms.agree": "",
+ "new.tabs.upload": "",
+ "new.tabs.update": "",
+ "new.tabs.pending": "",
+ "new.tabs.reviewed": "",
+ "new.update.selectLabel": "",
+ "new.update.searchPlaceholder": "",
+ "new.update.editing": "",
+ "new.update.before": "",
+ "new.update.after": "",
+ "new.pending.empty": "",
+ "new.pending.notFromManager": "",
+ "new.pending.approve": "",
+ "new.pending.approved": "",
+ "new.pending.approvalProgress": "",
+ "new.pending.decline": "",
+ "new.pending.declineHeading": "",
+ "new.pending.declineReason": "",
+ "new.pending.declined": "",
+ "new.pending.declinedBy": "",
+ "new.reviewed.empty": ""
+}
diff --git a/locales/nl/user.json b/locales/nl/user.json
index 50fd9ae89..ba2709109 100644
--- a/locales/nl/user.json
+++ b/locales/nl/user.json
@@ -3,6 +3,7 @@
"ign.short": "IGN",
"widget.bio": "",
"widget.bio-md": "",
+ "widget.trophies-owned": "",
"widget.badges-owned": "",
"widget.badges-authored": "",
"widget.badges-managed": "",
@@ -60,6 +61,7 @@
"widgets.maxReached": "",
"widgets.search": "",
"widgets.category.misc": "",
+ "widgets.category.trophies": "",
"widgets.category.badges": "",
"widgets.category.teams": "",
"widgets.category.sendouq": "",
@@ -72,6 +74,7 @@
"widgets.category.game-badges": "",
"widgets.description.bio": "",
"widgets.description.bio-md": "",
+ "widgets.description.trophies-owned": "",
"widgets.description.badges-owned": "",
"widgets.description.badges-authored": "",
"widgets.description.badges-managed": "",
diff --git a/locales/pl/calendar.json b/locales/pl/calendar.json
index 9947845bb..6f5f527ac 100644
--- a/locales/pl/calendar.json
+++ b/locales/pl/calendar.json
@@ -24,6 +24,8 @@
"forms.tags.placeholder": "Wybierz tag",
"forms.badges": "Odznaki",
"forms.badges.placeholder": "Wybierz odznakę",
+ "forms.trophy": "",
+ "forms.trophy.placeholder": "",
"forms.mapPool": "Pula map",
"forms.reportResultsHeader": "Zgłaszanie wyników dla {{eventName}}",
"forms.reportResultsInfo": "Możesz wybrać ile zgłosić wyników. Mogą to być wyniki drużyny zwyciężonej, Top 3 lub ile chcesz.",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index ae1e613f4..531f845e6 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -2,6 +2,7 @@
"pages.admin": "Admin",
"pages.api": "",
"pages.articles": "Artykuły",
+ "pages.trophies": "",
"pages.badges": "Odznaki",
"pages.plus": "Plus Server",
"pages.contributors": "Współtwórcy",
@@ -49,6 +50,7 @@
"header.adder.art": "",
"header.adder.vod": "",
"header.adder.plusSuggestion": "",
+ "header.adder.trophy": "",
"notifications.title": "",
"notifications.empty": "",
"notifications.seeAll": "",
@@ -70,6 +72,12 @@
"notifications.text.BADGE_ADDED": "",
"notifications.title.BADGE_MANAGER_ADDED": "",
"notifications.text.BADGE_MANAGER_ADDED": "",
+ "notifications.title.TROPHY_SUBMITTED": "",
+ "notifications.text.TROPHY_SUBMITTED": "",
+ "notifications.title.TROPHY_SUBMISSION_ACCEPTED": "",
+ "notifications.text.TROPHY_SUBMISSION_ACCEPTED": "",
+ "notifications.title.TROPHY_SUBMISSION_DECLINED": "",
+ "notifications.text.TROPHY_SUBMISSION_DECLINED": "",
"notifications.title.PLUS_VOTING_STARTED": "",
"notifications.text.PLUS_VOTING_STARTED": "",
"notifications.title.PLUS_SUGGESTION_ADDED": "",
@@ -191,6 +199,8 @@
"forms.userSearch.noResults": "",
"forms.tournamentSearch.placeholder": "",
"forms.tournamentSearch.noResults": "",
+ "forms.organizationSearch.placeholder": "",
+ "forms.organizationSearch.noResults": "",
"forms.teamSearch.placeholder": "",
"forms.teamSearch.noResults": "",
"forms.weaponSearch.placeholder": "",
@@ -382,6 +392,9 @@
"badges.selector.none": "",
"badges.selector.select": "",
"badges.selector.noneAvailable": "",
+ "trophies.selector.none": "",
+ "trophies.selector.select": "",
+ "trophies.selector.noneAvailable": "",
"api.title": "",
"api.description": "",
"api.noAccess": "",
diff --git a/locales/pl/forms.json b/locales/pl/forms.json
index bcb6af0b6..1abececf8 100644
--- a/locales/pl/forms.json
+++ b/locales/pl/forms.json
@@ -420,5 +420,15 @@
"placeholders.friendCode": "",
"unsavedChanges.title": "",
"unsavedChanges.body": "",
- "unsavedChanges.discard": ""
+ "unsavedChanges.discard": "",
+ "labels.trophyName": "",
+ "labels.trophyModel": "",
+ "labels.trophyOrganization": "",
+ "labels.trophyManager": "",
+ "labels.trophyInformation": "",
+ "labels.profileFavoriteTrophies": "",
+ "labels.profileHiddenTrophies": "",
+ "bottomTexts.trophyModel": "",
+ "errors.trophyNameTaken": "",
+ "errors.trophyWithBadges": ""
}
diff --git a/locales/pl/org.json b/locales/pl/org.json
index c4a8fa9df..95e15b4ce 100644
--- a/locales/pl/org.json
+++ b/locales/pl/org.json
@@ -20,6 +20,7 @@
"edit.form.series.seriesName.title": "",
"edit.form.series.showLeaderboard.title": "",
"edit.form.badges.title": "",
+ "edit.form.rewards.title": "",
"edit.form.errors.noUnadmin": "",
"banned.title": "",
"banned.empty": "",
diff --git a/locales/pl/tournament.json b/locales/pl/tournament.json
index d2cc29849..fe45d19ab 100644
--- a/locales/pl/tournament.json
+++ b/locales/pl/tournament.json
@@ -138,6 +138,8 @@
"actions.finalize.error.BADGE_NOT_ASSIGNED": "",
"actions.finalize.error.DUPLICATE_TOURNAMENT_TEAM_ID": "",
"actions.finalize.error.BADGE_NOT_FOUND": "",
+ "actions.finalize.error.TROPHY_NOT_FOUND": "",
+ "actions.finalize.error.TROPHY_NOT_ASSIGNED": "",
"actions.finalize.assignBadgesLater": "",
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
diff --git a/locales/pl/trophies.json b/locales/pl/trophies.json
new file mode 100644
index 000000000..7c93cecb8
--- /dev/null
+++ b/locales/pl/trophies.json
@@ -0,0 +1,67 @@
+{
+ "title": "",
+ "lookingForBadges": "",
+ "viewBadges": "",
+ "details.createdBy": "",
+ "details.managedBy": "",
+ "details.organization": "",
+ "details.owners": "",
+ "details.noOwners": "",
+ "details.tournamentHistory": "",
+ "details.noTournamentHistory": "",
+ "details.upcoming": "",
+ "display.viewTrophyPage": "",
+ "special.supporter.description": "",
+ "special.xp.description": "",
+ "new.form.limitReached": "",
+ "new.form.preview.light": "",
+ "new.form.preview.dark": "",
+ "new.specs.required": "",
+ "new.specs.recommended": "",
+ "new.specs.cameraTarget": "",
+ "new.specs.centered": "",
+ "new.specs.zoom": "",
+ "new.specs.angles": "",
+ "new.specs.background": "",
+ "new.specs.drawCalls": "",
+ "new.specs.polys": "",
+ "new.specs.effects": "",
+ "new.specs.currentValue": "",
+ "new.specs.stats.drawCalls": "",
+ "new.specs.stats.polys": "",
+ "new.specs.stats.effects": "",
+ "new.specs.stats.cameraTargetOff": "",
+ "new.specs.stats.backgroundNotAlpha": "",
+ "new.terms.title": "",
+ "new.terms.intro": "",
+ "new.terms.trustedOrg": "",
+ "new.terms.oneOff.title": "",
+ "new.terms.oneOff.pastTournaments": "",
+ "new.terms.oneOff.projectedTeams": "",
+ "new.terms.oneOff.signedUpTeams": "",
+ "new.terms.series.title": "",
+ "new.terms.series.consistentTeams": "",
+ "new.terms.disclaimer": "",
+ "new.terms.preApproval": "",
+ "new.terms.agree": "",
+ "new.tabs.upload": "",
+ "new.tabs.update": "",
+ "new.tabs.pending": "",
+ "new.tabs.reviewed": "",
+ "new.update.selectLabel": "",
+ "new.update.searchPlaceholder": "",
+ "new.update.editing": "",
+ "new.update.before": "",
+ "new.update.after": "",
+ "new.pending.empty": "",
+ "new.pending.notFromManager": "",
+ "new.pending.approve": "",
+ "new.pending.approved": "",
+ "new.pending.approvalProgress": "",
+ "new.pending.decline": "",
+ "new.pending.declineHeading": "",
+ "new.pending.declineReason": "",
+ "new.pending.declined": "",
+ "new.pending.declinedBy": "",
+ "new.reviewed.empty": ""
+}
diff --git a/locales/pl/user.json b/locales/pl/user.json
index bf508388c..765dc91bc 100644
--- a/locales/pl/user.json
+++ b/locales/pl/user.json
@@ -3,6 +3,7 @@
"ign.short": "IGN",
"widget.bio": "",
"widget.bio-md": "",
+ "widget.trophies-owned": "",
"widget.badges-owned": "",
"widget.badges-authored": "",
"widget.badges-managed": "",
@@ -60,6 +61,7 @@
"widgets.maxReached": "",
"widgets.search": "",
"widgets.category.misc": "",
+ "widgets.category.trophies": "",
"widgets.category.badges": "",
"widgets.category.teams": "",
"widgets.category.sendouq": "",
@@ -72,6 +74,7 @@
"widgets.category.game-badges": "",
"widgets.description.bio": "",
"widgets.description.bio-md": "",
+ "widgets.description.trophies-owned": "",
"widgets.description.badges-owned": "",
"widgets.description.badges-authored": "",
"widgets.description.badges-managed": "",
diff --git a/locales/pt-BR/calendar.json b/locales/pt-BR/calendar.json
index fa3deb98c..9cb764f2c 100644
--- a/locales/pt-BR/calendar.json
+++ b/locales/pt-BR/calendar.json
@@ -22,6 +22,8 @@
"forms.tags.placeholder": "Escolha uma marcação",
"forms.badges": "Prêmio(s) de insígnia",
"forms.badges.placeholder": "Escolha um prêmio de insígnia",
+ "forms.trophy": "",
+ "forms.trophy.placeholder": "",
"forms.mapPool": "Conjunto de mapas",
"forms.reportResultsHeader": "Declarando resultados para o(a) {{eventName}}",
"forms.reportResultsInfo": "Você decide quantos resultados declarar. Pode ser apenas o time vencedor, Top 3 ou qualquer outro tipo de placar que você quiser.",
diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json
index be3b668ae..3b3a84aab 100644
--- a/locales/pt-BR/common.json
+++ b/locales/pt-BR/common.json
@@ -2,6 +2,7 @@
"pages.admin": "Administrador",
"pages.api": "",
"pages.articles": "Artigos",
+ "pages.trophies": "",
"pages.badges": "Insígnias",
"pages.plus": "Servidor Plus",
"pages.contributors": "Contribuidores",
@@ -49,6 +50,7 @@
"header.adder.art": "",
"header.adder.vod": "",
"header.adder.plusSuggestion": "",
+ "header.adder.trophy": "",
"notifications.title": "",
"notifications.empty": "",
"notifications.seeAll": "",
@@ -70,6 +72,12 @@
"notifications.text.BADGE_ADDED": "",
"notifications.title.BADGE_MANAGER_ADDED": "",
"notifications.text.BADGE_MANAGER_ADDED": "",
+ "notifications.title.TROPHY_SUBMITTED": "",
+ "notifications.text.TROPHY_SUBMITTED": "",
+ "notifications.title.TROPHY_SUBMISSION_ACCEPTED": "",
+ "notifications.text.TROPHY_SUBMISSION_ACCEPTED": "",
+ "notifications.title.TROPHY_SUBMISSION_DECLINED": "",
+ "notifications.text.TROPHY_SUBMISSION_DECLINED": "",
"notifications.title.PLUS_VOTING_STARTED": "",
"notifications.text.PLUS_VOTING_STARTED": "",
"notifications.title.PLUS_SUGGESTION_ADDED": "",
@@ -191,6 +199,8 @@
"forms.userSearch.noResults": "",
"forms.tournamentSearch.placeholder": "",
"forms.tournamentSearch.noResults": "",
+ "forms.organizationSearch.placeholder": "",
+ "forms.organizationSearch.noResults": "",
"forms.teamSearch.placeholder": "",
"forms.teamSearch.noResults": "",
"forms.weaponSearch.placeholder": "",
@@ -382,6 +392,9 @@
"badges.selector.none": "",
"badges.selector.select": "",
"badges.selector.noneAvailable": "",
+ "trophies.selector.none": "",
+ "trophies.selector.select": "",
+ "trophies.selector.noneAvailable": "",
"api.title": "",
"api.description": "",
"api.noAccess": "",
diff --git a/locales/pt-BR/forms.json b/locales/pt-BR/forms.json
index 4f6092620..b824b9b82 100644
--- a/locales/pt-BR/forms.json
+++ b/locales/pt-BR/forms.json
@@ -420,5 +420,15 @@
"placeholders.friendCode": "",
"unsavedChanges.title": "",
"unsavedChanges.body": "",
- "unsavedChanges.discard": ""
+ "unsavedChanges.discard": "",
+ "labels.trophyName": "",
+ "labels.trophyModel": "",
+ "labels.trophyOrganization": "",
+ "labels.trophyManager": "",
+ "labels.trophyInformation": "",
+ "labels.profileFavoriteTrophies": "",
+ "labels.profileHiddenTrophies": "",
+ "bottomTexts.trophyModel": "",
+ "errors.trophyNameTaken": "",
+ "errors.trophyWithBadges": ""
}
diff --git a/locales/pt-BR/org.json b/locales/pt-BR/org.json
index c4a8fa9df..95e15b4ce 100644
--- a/locales/pt-BR/org.json
+++ b/locales/pt-BR/org.json
@@ -20,6 +20,7 @@
"edit.form.series.seriesName.title": "",
"edit.form.series.showLeaderboard.title": "",
"edit.form.badges.title": "",
+ "edit.form.rewards.title": "",
"edit.form.errors.noUnadmin": "",
"banned.title": "",
"banned.empty": "",
diff --git a/locales/pt-BR/tournament.json b/locales/pt-BR/tournament.json
index 7faf8cdf3..5a1f53909 100644
--- a/locales/pt-BR/tournament.json
+++ b/locales/pt-BR/tournament.json
@@ -136,6 +136,8 @@
"actions.finalize.error.BADGE_NOT_ASSIGNED": "",
"actions.finalize.error.DUPLICATE_TOURNAMENT_TEAM_ID": "",
"actions.finalize.error.BADGE_NOT_FOUND": "",
+ "actions.finalize.error.TROPHY_NOT_FOUND": "",
+ "actions.finalize.error.TROPHY_NOT_ASSIGNED": "",
"actions.finalize.assignBadgesLater": "",
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
diff --git a/locales/pt-BR/trophies.json b/locales/pt-BR/trophies.json
new file mode 100644
index 000000000..7c93cecb8
--- /dev/null
+++ b/locales/pt-BR/trophies.json
@@ -0,0 +1,67 @@
+{
+ "title": "",
+ "lookingForBadges": "",
+ "viewBadges": "",
+ "details.createdBy": "",
+ "details.managedBy": "",
+ "details.organization": "",
+ "details.owners": "",
+ "details.noOwners": "",
+ "details.tournamentHistory": "",
+ "details.noTournamentHistory": "",
+ "details.upcoming": "",
+ "display.viewTrophyPage": "",
+ "special.supporter.description": "",
+ "special.xp.description": "",
+ "new.form.limitReached": "",
+ "new.form.preview.light": "",
+ "new.form.preview.dark": "",
+ "new.specs.required": "",
+ "new.specs.recommended": "",
+ "new.specs.cameraTarget": "",
+ "new.specs.centered": "",
+ "new.specs.zoom": "",
+ "new.specs.angles": "",
+ "new.specs.background": "",
+ "new.specs.drawCalls": "",
+ "new.specs.polys": "",
+ "new.specs.effects": "",
+ "new.specs.currentValue": "",
+ "new.specs.stats.drawCalls": "",
+ "new.specs.stats.polys": "",
+ "new.specs.stats.effects": "",
+ "new.specs.stats.cameraTargetOff": "",
+ "new.specs.stats.backgroundNotAlpha": "",
+ "new.terms.title": "",
+ "new.terms.intro": "",
+ "new.terms.trustedOrg": "",
+ "new.terms.oneOff.title": "",
+ "new.terms.oneOff.pastTournaments": "",
+ "new.terms.oneOff.projectedTeams": "",
+ "new.terms.oneOff.signedUpTeams": "",
+ "new.terms.series.title": "",
+ "new.terms.series.consistentTeams": "",
+ "new.terms.disclaimer": "",
+ "new.terms.preApproval": "",
+ "new.terms.agree": "",
+ "new.tabs.upload": "",
+ "new.tabs.update": "",
+ "new.tabs.pending": "",
+ "new.tabs.reviewed": "",
+ "new.update.selectLabel": "",
+ "new.update.searchPlaceholder": "",
+ "new.update.editing": "",
+ "new.update.before": "",
+ "new.update.after": "",
+ "new.pending.empty": "",
+ "new.pending.notFromManager": "",
+ "new.pending.approve": "",
+ "new.pending.approved": "",
+ "new.pending.approvalProgress": "",
+ "new.pending.decline": "",
+ "new.pending.declineHeading": "",
+ "new.pending.declineReason": "",
+ "new.pending.declined": "",
+ "new.pending.declinedBy": "",
+ "new.reviewed.empty": ""
+}
diff --git a/locales/pt-BR/user.json b/locales/pt-BR/user.json
index 5828e6378..feda07d9c 100644
--- a/locales/pt-BR/user.json
+++ b/locales/pt-BR/user.json
@@ -3,6 +3,7 @@
"ign.short": "NNJ (IGN)",
"widget.bio": "",
"widget.bio-md": "",
+ "widget.trophies-owned": "",
"widget.badges-owned": "",
"widget.badges-authored": "",
"widget.badges-managed": "",
@@ -60,6 +61,7 @@
"widgets.maxReached": "",
"widgets.search": "",
"widgets.category.misc": "",
+ "widgets.category.trophies": "",
"widgets.category.badges": "",
"widgets.category.teams": "",
"widgets.category.sendouq": "",
@@ -72,6 +74,7 @@
"widgets.category.game-badges": "",
"widgets.description.bio": "",
"widgets.description.bio-md": "",
+ "widgets.description.trophies-owned": "",
"widgets.description.badges-owned": "",
"widgets.description.badges-authored": "",
"widgets.description.badges-managed": "",
diff --git a/locales/ru/calendar.json b/locales/ru/calendar.json
index 19109edd2..8a28c7cb8 100644
--- a/locales/ru/calendar.json
+++ b/locales/ru/calendar.json
@@ -24,6 +24,8 @@
"forms.tags.placeholder": "Выберите тег",
"forms.badges": "Призовые значки",
"forms.badges.placeholder": "Выберите призовой значок",
+ "forms.trophy": "",
+ "forms.trophy.placeholder": "",
"forms.mapPool": "Список карт",
"forms.reportResultsHeader": "Указать результаты для {{eventName}}",
"forms.reportResultsInfo": "Вы можете указать столько результатов, сколько сочтёте нужным. Это может быть команда победителей, топ-3 или как вы сами захотите.",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 59061ad30..f99270bc3 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -2,6 +2,7 @@
"pages.admin": "Админ",
"pages.api": "",
"pages.articles": "Статьи",
+ "pages.trophies": "",
"pages.badges": "Значки",
"pages.plus": "Plus Server",
"pages.contributors": "Помощники",
@@ -49,6 +50,7 @@
"header.adder.art": "Арт",
"header.adder.vod": "VoD",
"header.adder.plusSuggestion": "Рекомендация Плюс",
+ "header.adder.trophy": "",
"notifications.title": "Уведомления",
"notifications.empty": "Пока нет, проверьте позже",
"notifications.seeAll": "Смотреть все",
@@ -70,6 +72,12 @@
"notifications.text.BADGE_ADDED": "Новая награда ({{badgeName}})",
"notifications.title.BADGE_MANAGER_ADDED": "Получены Права Менеджера",
"notifications.text.BADGE_MANAGER_ADDED": "Вы получили права менеджера для награды {{badgeName}}",
+ "notifications.title.TROPHY_SUBMITTED": "",
+ "notifications.text.TROPHY_SUBMITTED": "",
+ "notifications.title.TROPHY_SUBMISSION_ACCEPTED": "",
+ "notifications.text.TROPHY_SUBMISSION_ACCEPTED": "",
+ "notifications.title.TROPHY_SUBMISSION_DECLINED": "",
+ "notifications.text.TROPHY_SUBMISSION_DECLINED": "",
"notifications.title.PLUS_VOTING_STARTED": "Голосование в Плюс Сервер Стартовало",
"notifications.text.PLUS_VOTING_STARTED": "Голосование в Плюс Сервер сезона {{seasonNth}} стартовало",
"notifications.title.PLUS_SUGGESTION_ADDED": "Рекомендация в Плюс Добавлена",
@@ -191,6 +199,8 @@
"forms.userSearch.noResults": "",
"forms.tournamentSearch.placeholder": "",
"forms.tournamentSearch.noResults": "",
+ "forms.organizationSearch.placeholder": "",
+ "forms.organizationSearch.noResults": "",
"forms.teamSearch.placeholder": "",
"forms.teamSearch.noResults": "",
"forms.weaponSearch.placeholder": "",
@@ -382,6 +392,9 @@
"badges.selector.none": "Награды не выбраны",
"badges.selector.select": "Выберите награды для добавления",
"badges.selector.noneAvailable": "",
+ "trophies.selector.none": "",
+ "trophies.selector.select": "",
+ "trophies.selector.noneAvailable": "",
"api.title": "",
"api.description": "",
"api.noAccess": "",
diff --git a/locales/ru/forms.json b/locales/ru/forms.json
index 71960c3dd..86043f6f5 100644
--- a/locales/ru/forms.json
+++ b/locales/ru/forms.json
@@ -420,5 +420,15 @@
"placeholders.friendCode": "",
"unsavedChanges.title": "",
"unsavedChanges.body": "",
- "unsavedChanges.discard": ""
+ "unsavedChanges.discard": "",
+ "labels.trophyName": "",
+ "labels.trophyModel": "",
+ "labels.trophyOrganization": "",
+ "labels.trophyManager": "",
+ "labels.trophyInformation": "",
+ "labels.profileFavoriteTrophies": "",
+ "labels.profileHiddenTrophies": "",
+ "bottomTexts.trophyModel": "",
+ "errors.trophyNameTaken": "",
+ "errors.trophyWithBadges": ""
}
diff --git a/locales/ru/org.json b/locales/ru/org.json
index 6eefa3f4f..2029e5b7d 100644
--- a/locales/ru/org.json
+++ b/locales/ru/org.json
@@ -20,6 +20,7 @@
"edit.form.series.seriesName.title": "Название серии",
"edit.form.series.showLeaderboard.title": "Показать таблицу лидеров",
"edit.form.badges.title": "Награды",
+ "edit.form.rewards.title": "",
"edit.form.errors.noUnadmin": "Невозможно удалить собственную роль администратора",
"banned.title": "",
"banned.empty": "",
diff --git a/locales/ru/tournament.json b/locales/ru/tournament.json
index 4f4044891..caa3438af 100644
--- a/locales/ru/tournament.json
+++ b/locales/ru/tournament.json
@@ -138,6 +138,8 @@
"actions.finalize.error.BADGE_NOT_ASSIGNED": "",
"actions.finalize.error.DUPLICATE_TOURNAMENT_TEAM_ID": "",
"actions.finalize.error.BADGE_NOT_FOUND": "",
+ "actions.finalize.error.TROPHY_NOT_FOUND": "",
+ "actions.finalize.error.TROPHY_NOT_ASSIGNED": "",
"actions.finalize.assignBadgesLater": "",
"finalize.receivingTeam.label": "",
"finalize.receivingTeam.placeholder": "",
diff --git a/locales/ru/trophies.json b/locales/ru/trophies.json
new file mode 100644
index 000000000..7c93cecb8
--- /dev/null
+++ b/locales/ru/trophies.json
@@ -0,0 +1,67 @@
+{
+ "title": "",
+ "lookingForBadges": "",
+ "viewBadges": "",
+ "details.createdBy": "",
+ "details.managedBy": "",
+ "details.organization": "",
+ "details.owners": "",
+ "details.noOwners": "",
+ "details.tournamentHistory": "",
+ "details.noTournamentHistory": "",
+ "details.upcoming": "",
+ "display.viewTrophyPage": "",
+ "special.supporter.description": "",
+ "special.xp.description": "",
+ "new.form.limitReached": "",
+ "new.form.preview.light": "",
+ "new.form.preview.dark": "",
+ "new.specs.required": "",
+ "new.specs.recommended": "",
+ "new.specs.cameraTarget": "",
+ "new.specs.centered": "",
+ "new.specs.zoom": "",
+ "new.specs.angles": "",
+ "new.specs.background": "",
+ "new.specs.drawCalls": "",
+ "new.specs.polys": "",
+ "new.specs.effects": "",
+ "new.specs.currentValue": "",
+ "new.specs.stats.drawCalls": "",
+ "new.specs.stats.polys": "",
+ "new.specs.stats.effects": "",
+ "new.specs.stats.cameraTargetOff": "",
+ "new.specs.stats.backgroundNotAlpha": "",
+ "new.terms.title": "",
+ "new.terms.intro": "",
+ "new.terms.trustedOrg": "",
+ "new.terms.oneOff.title": "",
+ "new.terms.oneOff.pastTournaments": "",
+ "new.terms.oneOff.projectedTeams": "",
+ "new.terms.oneOff.signedUpTeams": "",
+ "new.terms.series.title": "",
+ "new.terms.series.consistentTeams": "",
+ "new.terms.disclaimer": "",
+ "new.terms.preApproval": "",
+ "new.terms.agree": "",
+ "new.tabs.upload": "",
+ "new.tabs.update": "",
+ "new.tabs.pending": "",
+ "new.tabs.reviewed": "",
+ "new.update.selectLabel": "",
+ "new.update.searchPlaceholder": "",
+ "new.update.editing": "",
+ "new.update.before": "",
+ "new.update.after": "",
+ "new.pending.empty": "",
+ "new.pending.notFromManager": "",
+ "new.pending.approve": "",
+ "new.pending.approved": "",
+ "new.pending.approvalProgress": "",
+ "new.pending.decline": "",
+ "new.pending.declineHeading": "",
+ "new.pending.declineReason": "",
+ "new.pending.declined": "",
+ "new.pending.declinedBy": "",
+ "new.reviewed.empty": ""
+}
diff --git a/locales/ru/user.json b/locales/ru/user.json
index 1302b2702..f07ae214c 100644
--- a/locales/ru/user.json
+++ b/locales/ru/user.json
@@ -3,6 +3,7 @@
"ign.short": "Ник",
"widget.bio": "",
"widget.bio-md": "",
+ "widget.trophies-owned": "",
"widget.badges-owned": "",
"widget.badges-authored": "",
"widget.badges-managed": "",
@@ -60,6 +61,7 @@
"widgets.maxReached": "",
"widgets.search": "",
"widgets.category.misc": "",
+ "widgets.category.trophies": "",
"widgets.category.badges": "",
"widgets.category.teams": "",
"widgets.category.sendouq": "",
@@ -72,6 +74,7 @@
"widgets.category.game-badges": "",
"widgets.description.bio": "",
"widgets.description.bio-md": "",
+ "widgets.description.trophies-owned": "",
"widgets.description.badges-owned": "",
"widgets.description.badges-authored": "",
"widgets.description.badges-managed": "",
diff --git a/locales/zh/calendar.json b/locales/zh/calendar.json
index 088ad6c36..5ab7946b3 100644
--- a/locales/zh/calendar.json
+++ b/locales/zh/calendar.json
@@ -18,6 +18,8 @@
"forms.tags.placeholder": "选择标签",
"forms.badges": "徽章奖励",
"forms.badges.placeholder": "选择一项徽章奖励",
+ "forms.trophy": "",
+ "forms.trophy.placeholder": "",
"forms.mapPool": "场地池",
"forms.reportResultsHeader": "汇报 {{eventName}} 的结果",
"forms.reportResultsInfo": "您可以自定义要显示的赛事结果。可以选择仅冠军队伍、前三名或其他结果。",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index a15d4cfbf..701fd99f9 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -2,6 +2,7 @@
"pages.admin": "管理",
"pages.api": "API",
"pages.articles": "文章",
+ "pages.trophies": "",
"pages.badges": "徽章",
"pages.plus": "Plus Server",
"pages.contributors": "贡献者",
@@ -49,6 +50,7 @@
"header.adder.art": "插画",
"header.adder.vod": "视频",
"header.adder.plusSuggestion": "Plus 推荐",
+ "header.adder.trophy": "",
"notifications.title": "通知",
"notifications.empty": "还没有通知,晚点再来看看吧",
"notifications.seeAll": "查看全部",
@@ -70,6 +72,12 @@
"notifications.text.BADGE_ADDED": "获得了新徽章({{badgeName}})",
"notifications.title.BADGE_MANAGER_ADDED": "已授予管理员权限",
"notifications.text.BADGE_MANAGER_ADDED": "您已获得徽章【{{badgeName}}】的管理员权限",
+ "notifications.title.TROPHY_SUBMITTED": "",
+ "notifications.text.TROPHY_SUBMITTED": "",
+ "notifications.title.TROPHY_SUBMISSION_ACCEPTED": "",
+ "notifications.text.TROPHY_SUBMISSION_ACCEPTED": "",
+ "notifications.title.TROPHY_SUBMISSION_DECLINED": "",
+ "notifications.text.TROPHY_SUBMISSION_DECLINED": "",
"notifications.title.PLUS_VOTING_STARTED": "Plus 投票已开始",
"notifications.text.PLUS_VOTING_STARTED": "第 {{seasonNth}} 赛季的 Plus Server 资格投票已开始",
"notifications.title.PLUS_SUGGESTION_ADDED": "收到 Plus 推荐",
@@ -191,6 +199,8 @@
"forms.userSearch.noResults": "未找到符合搜索条件的用户",
"forms.tournamentSearch.placeholder": "按名称搜索赛事...",
"forms.tournamentSearch.noResults": "未找到符合搜索条件的赛事",
+ "forms.organizationSearch.placeholder": "",
+ "forms.organizationSearch.noResults": "",
"forms.teamSearch.placeholder": "按名称寻找队伍...",
"forms.teamSearch.noResults": "未找到符合搜索条件的队伍",
"forms.weaponSearch.placeholder": "选择一个武器",
@@ -382,6 +392,9 @@
"badges.selector.none": "没有选择任何徽章",
"badges.selector.select": "选择徽章并添加",
"badges.selector.noneAvailable": "没有可用徽章。组织成员必须为徽章的管理员才能添加徽章。",
+ "trophies.selector.none": "",
+ "trophies.selector.select": "",
+ "trophies.selector.noneAvailable": "",
"api.title": "API 访问权限",
"api.description": "生成一个 API 令牌以访问 sendou.ink 的 API。有关可用的端点、使用示例和需要遵守的指南,请参阅 <1>API 文档1>。",
"api.noAccess": "您目前没有 API 的访问权限。访问权限仅授予赞助者(Supporter 级别或更高)、管理员、赛事组织者,或者是常设赛事组织的主播。",
diff --git a/locales/zh/forms.json b/locales/zh/forms.json
index 6855fa076..bbf9a8172 100644
--- a/locales/zh/forms.json
+++ b/locales/zh/forms.json
@@ -420,5 +420,15 @@
"placeholders.friendCode": "",
"unsavedChanges.title": "",
"unsavedChanges.body": "",
- "unsavedChanges.discard": ""
+ "unsavedChanges.discard": "",
+ "labels.trophyName": "",
+ "labels.trophyModel": "",
+ "labels.trophyOrganization": "",
+ "labels.trophyManager": "",
+ "labels.trophyInformation": "",
+ "labels.profileFavoriteTrophies": "",
+ "labels.profileHiddenTrophies": "",
+ "bottomTexts.trophyModel": "",
+ "errors.trophyNameTaken": "",
+ "errors.trophyWithBadges": ""
}
diff --git a/locales/zh/org.json b/locales/zh/org.json
index da7350609..96350f243 100644
--- a/locales/zh/org.json
+++ b/locales/zh/org.json
@@ -20,6 +20,7 @@
"edit.form.series.seriesName.title": "系列赛名称",
"edit.form.series.showLeaderboard.title": "显示排行榜",
"edit.form.badges.title": "徽章",
+ "edit.form.rewards.title": "",
"edit.form.errors.noUnadmin": "您不能移除自己的管理员身份",
"banned.title": "被封禁的玩家",
"banned.empty": "当前没有玩家被此组织封禁。",
diff --git a/locales/zh/tournament.json b/locales/zh/tournament.json
index bca8a13d9..32caa1c54 100644
--- a/locales/zh/tournament.json
+++ b/locales/zh/tournament.json
@@ -132,6 +132,8 @@
"actions.finalize.error.BADGE_NOT_ASSIGNED": "尚未将所有徽章发放给队伍",
"actions.finalize.error.DUPLICATE_TOURNAMENT_TEAM_ID": "同一徽章被发放给了多个队伍",
"actions.finalize.error.BADGE_NOT_FOUND": "未找到预期的徽章",
+ "actions.finalize.error.TROPHY_NOT_FOUND": "",
+ "actions.finalize.error.TROPHY_NOT_ASSIGNED": "",
"actions.finalize.assignBadgesLater": "稍后手动分发徽章",
"finalize.receivingTeam.label": "接收队伍",
"finalize.receivingTeam.placeholder": "选择队伍...",
diff --git a/locales/zh/trophies.json b/locales/zh/trophies.json
new file mode 100644
index 000000000..7c93cecb8
--- /dev/null
+++ b/locales/zh/trophies.json
@@ -0,0 +1,67 @@
+{
+ "title": "",
+ "lookingForBadges": "",
+ "viewBadges": "",
+ "details.createdBy": "",
+ "details.managedBy": "",
+ "details.organization": "",
+ "details.owners": "",
+ "details.noOwners": "",
+ "details.tournamentHistory": "",
+ "details.noTournamentHistory": "",
+ "details.upcoming": "",
+ "display.viewTrophyPage": "",
+ "special.supporter.description": "",
+ "special.xp.description": "",
+ "new.form.limitReached": "",
+ "new.form.preview.light": "",
+ "new.form.preview.dark": "",
+ "new.specs.required": "",
+ "new.specs.recommended": "",
+ "new.specs.cameraTarget": "",
+ "new.specs.centered": "",
+ "new.specs.zoom": "",
+ "new.specs.angles": "",
+ "new.specs.background": "",
+ "new.specs.drawCalls": "",
+ "new.specs.polys": "",
+ "new.specs.effects": "",
+ "new.specs.currentValue": "",
+ "new.specs.stats.drawCalls": "",
+ "new.specs.stats.polys": "",
+ "new.specs.stats.effects": "",
+ "new.specs.stats.cameraTargetOff": "",
+ "new.specs.stats.backgroundNotAlpha": "",
+ "new.terms.title": "",
+ "new.terms.intro": "",
+ "new.terms.trustedOrg": "",
+ "new.terms.oneOff.title": "",
+ "new.terms.oneOff.pastTournaments": "",
+ "new.terms.oneOff.projectedTeams": "",
+ "new.terms.oneOff.signedUpTeams": "",
+ "new.terms.series.title": "",
+ "new.terms.series.consistentTeams": "",
+ "new.terms.disclaimer": "",
+ "new.terms.preApproval": "",
+ "new.terms.agree": "",
+ "new.tabs.upload": "",
+ "new.tabs.update": "",
+ "new.tabs.pending": "",
+ "new.tabs.reviewed": "",
+ "new.update.selectLabel": "",
+ "new.update.searchPlaceholder": "",
+ "new.update.editing": "",
+ "new.update.before": "",
+ "new.update.after": "",
+ "new.pending.empty": "",
+ "new.pending.notFromManager": "",
+ "new.pending.approve": "",
+ "new.pending.approved": "",
+ "new.pending.approvalProgress": "",
+ "new.pending.decline": "",
+ "new.pending.declineHeading": "",
+ "new.pending.declineReason": "",
+ "new.pending.declined": "",
+ "new.pending.declinedBy": "",
+ "new.reviewed.empty": ""
+}
diff --git a/locales/zh/user.json b/locales/zh/user.json
index 5a088d7fc..0cf5b0a32 100644
--- a/locales/zh/user.json
+++ b/locales/zh/user.json
@@ -3,6 +3,7 @@
"ign.short": "IGN",
"widget.bio": "个人简介",
"widget.bio-md": "个人简介",
+ "widget.trophies-owned": "",
"widget.badges-owned": "拥有徽章",
"widget.badges-authored": "创作的徽章",
"widget.badges-managed": "管理的徽章",
@@ -60,6 +61,7 @@
"widgets.maxReached": "已达到小组件上限",
"widgets.search": "搜索小组件...",
"widgets.category.misc": "其他",
+ "widgets.category.trophies": "",
"widgets.category.badges": "徽章",
"widgets.category.teams": "队伍",
"widgets.category.sendouq": "SendouQ",
@@ -72,6 +74,7 @@
"widgets.category.game-badges": "游戏内徽章",
"widgets.description.bio": "自由分享关于您自己的自定义信息",
"widgets.description.bio-md": "自由分享关于您自己的自定义信息,支持 Markdown 语法",
+ "widgets.description.trophies-owned": "",
"widgets.description.badges-owned": "展示您获得的徽章",
"widgets.description.badges-authored": "展示由您创作的徽章",
"widgets.description.badges-managed": "展示由您管理的徽章",
diff --git a/migrations/163-trophies.js b/migrations/163-trophies.js
new file mode 100644
index 000000000..312b92f71
--- /dev/null
+++ b/migrations/163-trophies.js
@@ -0,0 +1,116 @@
+export function up(db) {
+ db.transaction(() => {
+ db.prepare(
+ /*sql*/ `
+ create table "Trophy" (
+ "id" integer primary key,
+ "name" text not null,
+ "model" text not null,
+ "code" text unique,
+ "organizationId" integer,
+ "creatorId" integer,
+ "managerId" integer,
+ foreign key ("organizationId") references "TournamentOrganization"("id") on delete set null,
+ foreign key ("creatorId") references "User"("id"),
+ foreign key ("managerId") references "User"("id")
+ ) strict
+ `,
+ ).run();
+
+ db.prepare(
+ /*sql*/ `
+ create table "TrophyOwner" (
+ "trophyId" integer not null,
+ "userId" integer not null,
+ "tournamentId" integer not null,
+ "tier" integer,
+ foreign key ("trophyId") references "Trophy"("id") on delete cascade,
+ foreign key ("userId") references "User"("id") on delete cascade,
+ foreign key ("tournamentId") references "Tournament"("id") on delete cascade
+ ) strict
+ `,
+ ).run();
+
+ db.prepare(
+ /*sql*/ `create unique index "trophy_owner_tournament_user_unique" on "TrophyOwner"("tournamentId", "userId", "trophyId")`,
+ ).run();
+
+ db.prepare(
+ /*sql*/ `create index "trophy_owner_user_id" on "TrophyOwner"("userId")`,
+ ).run();
+
+ db.prepare(
+ /*sql*/ `create index "trophy_owner_trophy_id" on "TrophyOwner"("trophyId", "userId")`,
+ ).run();
+
+ db.prepare(
+ /*sql*/ `
+ create table "SpecialTrophyOwner" (
+ "trophyId" integer not null,
+ "userId" integer not null,
+ "createdAt" integer not null,
+ primary key ("trophyId", "userId"),
+ foreign key ("trophyId") references "Trophy"("id") on delete cascade,
+ foreign key ("userId") references "User"("id") on delete cascade
+ ) strict
+ `,
+ ).run();
+
+ db.prepare(
+ /*sql*/ `
+ create table "PendingTrophy" (
+ "id" integer primary key,
+ "name" text not null,
+ "model" text not null,
+ "description" text not null,
+ "organizationId" integer,
+ "submitterUserId" integer not null,
+ "createdAt" integer not null,
+ "declineReason" text,
+ "declinedAt" integer,
+ "declinedByUserId" integer,
+ "targetTrophyId" integer,
+ "managerId" integer,
+ foreign key ("organizationId") references "TournamentOrganization"("id") on delete set null,
+ foreign key ("submitterUserId") references "User"("id") on delete cascade,
+ foreign key ("declinedByUserId") references "User"("id") on delete set null,
+ foreign key ("targetTrophyId") references "Trophy"("id") on delete cascade,
+ foreign key ("managerId") references "User"("id") on delete set null
+ ) strict
+ `,
+ ).run();
+
+ db.prepare(
+ /*sql*/ `create index "pending_trophy_submitter_idx" on "PendingTrophy"("submitterUserId")`,
+ ).run();
+
+ db.prepare(
+ /*sql*/ `
+ create table "PendingTrophyApproval" (
+ "pendingTrophyId" integer not null,
+ "userId" integer not null,
+ "createdAt" integer not null,
+ foreign key ("pendingTrophyId") references "PendingTrophy"("id") on delete cascade,
+ foreign key ("userId") references "User"("id"),
+ unique("pendingTrophyId", "userId")
+ ) strict
+ `,
+ ).run();
+
+ db.prepare(
+ /*sql*/ `alter table "CalendarEvent" add column "trophyId" integer references "Trophy"("id") on delete set null`,
+ ).run();
+
+ db.prepare(
+ /*sql*/ `create index "calendar_event_trophy_id" on "CalendarEvent"("trophyId")`,
+ ).run();
+
+ db.prepare(
+ /*sql*/ `alter table "User" add column "favoriteTrophyIds" text`,
+ ).run();
+
+ db.prepare(
+ /*sql*/ `alter table "User" add column "hiddenTrophyIds" text`,
+ ).run();
+ })();
+}
diff --git a/package.json b/package.json
index e825c6a5e..2b64dd54a 100644
--- a/package.json
+++ b/package.json
@@ -54,6 +54,7 @@
"@react-router/serve": "8.1.0",
"@sentry/react-router": "10.67.0",
"@tldraw/tldraw": "3.12.1",
+ "@types/pako": "^2.0.4",
"@zumer/snapdom": "2.16.0",
"better-sqlite3": "13.0.1",
"chart.js": "4.5.1",
@@ -67,7 +68,6 @@
"i18next-http-backend": "4.0.0",
"ics": "3.12.0",
"isbot": "5.2.1",
- "jsoncrush": "1.1.8",
"kysely": "0.29.0",
"lucide-react": "1.25.0",
"markdown-to-jsx": "9.9.0",
@@ -77,7 +77,9 @@
"nprogress": "0.2.0",
"openskill": "5.0.1",
"p-limit": "7.3.1",
+ "pako": "^2.1.0",
"partysocket": "1.3.0",
+ "picocad2-web": "^1.2.12",
"qrcode.react": "4.2.0",
"react": "19.2.8",
"react-aria-components": "1.19.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ae1ff9d04..2f299a92f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -54,6 +54,9 @@ importers:
'@tldraw/tldraw':
specifier: 3.12.1
version: 3.12.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@types/pako':
+ specifier: ^2.0.4
+ version: 2.0.4
'@zumer/snapdom':
specifier: 2.16.0
version: 2.16.0
@@ -93,9 +96,6 @@ importers:
isbot:
specifier: 5.2.1
version: 5.2.1
- jsoncrush:
- specifier: 1.1.8
- version: 1.1.8
kysely:
specifier: 0.29.0
version: 0.29.0(patch_hash=6f395b25414c1ef852485fa3a03d2d521816064697d8c4bff337e2b24d19daa6)
@@ -123,9 +123,15 @@ importers:
p-limit:
specifier: 7.3.1
version: 7.3.1
+ pako:
+ specifier: ^2.1.0
+ version: 2.1.0
partysocket:
specifier: 1.3.0
version: 1.3.0(react@19.2.8)
+ picocad2-web:
+ specifier: ^1.2.12
+ version: 1.2.13
qrcode.react:
specifier: 4.2.0
version: 4.2.0(react@19.2.8)
@@ -2650,6 +2656,9 @@ packages:
'@types/nprogress@0.2.3':
resolution: {integrity: sha512-k7kRA033QNtC+gLc4VPlfnue58CM1iQLgn1IMAU8VPHGOj7oIHPp9UlhedEnD/Gl8evoCjwkZjlBORtZ3JByUA==}
+ '@types/pako@2.0.4':
+ resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==}
+
'@types/ramda@0.31.1':
resolution: {integrity: sha512-Vt6sFXnuRpzaEj+yeutA0q3bcAsK7wdPuASIzR9LXqL4gJPyFw8im9qchlbp4ltuf3kDEIRmPJTD/Fkg60dn7g==}
@@ -3310,6 +3319,9 @@ packages:
get-tsconfig@4.14.0:
resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==}
+ gl-matrix@3.4.4:
+ resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==}
+
glob@13.0.6:
resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
engines: {node: 18 || 20 || >=22}
@@ -3454,9 +3466,6 @@ packages:
engines: {node: '>=6'}
hasBin: true
- jsoncrush@1.1.8:
- resolution: {integrity: sha512-lvIMGzMUA0fjuqwNcxlTNRq2bibPZ9auqT/LyGdlR5hvydJtA/BasSgkx4qclqTKVeTidrJvsS/oVjlTCPQ4Nw==}
-
jsonfile@6.2.0:
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
@@ -3809,6 +3818,9 @@ packages:
resolution: {integrity: sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA==}
engines: {node: '>=18'}
+ pako@2.1.0:
+ resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
+
parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
@@ -3842,6 +3854,10 @@ packages:
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+ picocad2-web@1.2.13:
+ resolution: {integrity: sha512-+kNmbhJdY2UdQiTMy3LwXUxFBMq+ptliH4Sh2CHgpbEKDHmZquGhKVUodrFnPiBm38cdnyWr1a9qBJAonaK8+w==}
+ engines: {node: '>=18.0.0'}
+
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -4377,6 +4393,9 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+ twgl.js@7.0.0:
+ resolution: {integrity: sha512-9HHZ8emdZb2nKPU0FDIGDlkp6AD9rWFv7ThqdbmUqyBhD3WTQpLN+89w4B+YshGwD1fL6PMVpVKZqC0pikgmcA==}
+
type-fest@2.19.0:
resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==}
engines: {node: '>=12.20'}
@@ -7281,6 +7300,8 @@ snapshots:
'@types/nprogress@0.2.3': {}
+ '@types/pako@2.0.4': {}
+
'@types/ramda@0.31.1':
dependencies:
types-ramda: 0.31.0
@@ -7888,6 +7909,8 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
+ gl-matrix@3.4.4: {}
+
glob@13.0.6:
dependencies:
minimatch: 10.2.6
@@ -8016,8 +8039,6 @@ snapshots:
json5@2.2.3: {}
- jsoncrush@1.1.8: {}
-
jsonfile@6.2.0:
dependencies:
universalify: 2.0.1
@@ -8340,6 +8361,8 @@ snapshots:
p-map@7.0.5: {}
+ pako@2.1.0: {}
+
parseurl@1.3.3: {}
partysocket@1.3.0(react@19.2.8):
@@ -8363,6 +8386,11 @@ snapshots:
pathe@2.0.3: {}
+ picocad2-web@1.2.13:
+ dependencies:
+ gl-matrix: 3.4.4
+ twgl.js: 7.0.0
+
picocolors@1.1.1: {}
picomatch@4.0.4: {}
@@ -8966,6 +8994,8 @@ snapshots:
tslib@2.8.1: {}
+ twgl.js@7.0.0: {}
+
type-fest@2.19.0: {}
type-is@2.1.0:
diff --git a/scripts/benchmark-db/cases.ts b/scripts/benchmark-db/cases.ts
index 475e0a77c..a80a02bfe 100644
--- a/scripts/benchmark-db/cases.ts
+++ b/scripts/benchmark-db/cases.ts
@@ -38,6 +38,7 @@ import * as TournamentMatchVodRepository from "~/features/tournament-bracket/Tou
import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server";
import * as TournamentMatchRepository from "~/features/tournament-match/TournamentMatchRepository.server";
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
+import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import * as VodRepository from "~/features/vods/VodRepository.server";
@@ -947,6 +948,21 @@ export function buildCases(fx: Fixtures): {
TournamentTeamRepository.findRecentlyPlayedMapsByIds({ teamIds }),
);
+ // TrophyRepository
+ addStatic("TrophyRepository.all", () => TrophyRepository.all());
+ add("TrophyRepository.findById", fx.trophy, (trophy) =>
+ TrophyRepository.findById(trophy.heavyTrophyId),
+ );
+ add("TrophyRepository.findByOwnerUserId", fx.trophy, (trophy) =>
+ TrophyRepository.findByOwnerUserId(trophy.ownerUserId),
+ );
+ add("TrophyRepository.findTournamentsByTrophyId", fx.trophy, (trophy) =>
+ TrophyRepository.findTournamentsByTrophyId(trophy.heavyTrophyId),
+ );
+ add("TrophyRepository.findWinsByOwner", fx.trophy, (trophy) =>
+ TrophyRepository.findWinsByOwner(trophy.wins),
+ );
+
// UserCardRepository
add("UserCardRepository.findAllByUserIds", fx.manyUserIds, (userIds) =>
UserCardRepository.findAllByUserIds({
diff --git a/scripts/benchmark-db/fixtures.ts b/scripts/benchmark-db/fixtures.ts
index 29047416c..27fd4fe7f 100644
--- a/scripts/benchmark-db/fixtures.ts
+++ b/scripts/benchmark-db/fixtures.ts
@@ -61,6 +61,11 @@ export interface Fixtures {
badgeOwnerUserId: number | null;
badgeAuthorId: number | null;
badgeManagerUserId: number | null;
+ trophy: {
+ heavyTrophyId: number;
+ ownerUserId: number;
+ wins: { trophyId: number; userId: number };
+ } | null;
manyUserIds: number[] | null;
notification: { userId: number; type: Tables["Notification"]["type"] } | null;
heavyAssociation: {
@@ -143,6 +148,7 @@ export async function resolveFixtures(): Promise {
badgeOwnerUserId: await resolveBadgeOwnerUserId(),
badgeAuthorId: await resolveBadgeAuthorId(),
badgeManagerUserId: await resolveBadgeManagerUserId(),
+ trophy: await resolveTrophy(),
manyUserIds: await resolveManyUserIds(heavyTournamentId),
notification: await resolveNotification(),
heavyAssociation: await resolveHeavyAssociation(),
@@ -782,6 +788,45 @@ async function resolveBadgeManagerUserId() {
return row?.userId ?? null;
}
+async function resolveTrophy() {
+ const heavyTrophyRow = await db
+ .selectFrom("TrophyOwner")
+ .select(({ fn }) => ["trophyId", fn.countAll().as("count")])
+ .groupBy("trophyId")
+ .orderBy("count", "desc")
+ .limit(1)
+ .executeTakeFirst();
+ if (!heavyTrophyRow) return null;
+
+ const ownerRow = await db
+ .selectFrom("TrophyOwner")
+ .select(({ fn }) => ["userId", fn.countAll().as("count")])
+ .groupBy("userId")
+ .orderBy("count", "desc")
+ .limit(1)
+ .executeTakeFirst();
+ if (!ownerRow) return null;
+
+ const winsRow = await db
+ .selectFrom("TrophyOwner")
+ .select(({ fn }) => [
+ "trophyId",
+ "userId",
+ fn.countAll().as("count"),
+ ])
+ .groupBy(["trophyId", "userId"])
+ .orderBy("count", "desc")
+ .limit(1)
+ .executeTakeFirst();
+ if (!winsRow) return null;
+
+ return {
+ heavyTrophyId: heavyTrophyRow.trophyId,
+ ownerUserId: ownerRow.userId,
+ wins: { trophyId: winsRow.trophyId, userId: winsRow.userId },
+ };
+}
+
async function resolveManyUserIds(heavyTournamentId: number | null) {
if (heavyTournamentId !== null) {
const rows = await db
diff --git a/vite.config.ts b/vite.config.ts
index ef0f63740..ef204dae3 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -14,9 +14,6 @@ export default defineConfig((config) => {
ssrFiles: ["./app/entry.server.tsx"],
},
},
- ssr: {
- noExternal: ["react-charts", "react-use"],
- },
plugins: [
{
// Wraps CSS modules in @layer components so utility classes always win.
@@ -127,22 +124,27 @@ export default defineConfig((config) => {
"edmonds-blossom-fixed",
"i18next-browser-languagedetector",
"i18next-http-backend",
- "jsoncrush",
"kysely",
"kysely/helpers/sqlite",
"markdown-to-jsx",
+ "nanoid",
"neverthrow",
"openskill",
+ "pako",
"partysocket",
+ "picocad2-web",
"qrcode.react",
"react-chartjs-2",
"react-flip-toolkit",
+ "react-use-draggable-scroll",
"remeda",
"remix-auth",
"remix-auth-oauth2",
"remix-i18next",
"sql-formatter",
"swr/immutable",
+ "web-haptics/react",
+ "zod",
],
},
};