Tournament Trophies (#3245)

This commit is contained in:
hfcRed 2026-07-31 08:51:49 +02:00 committed by GitHub
parent 293d6013be
commit 3df1f9ed19
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
244 changed files with 9333 additions and 242 deletions

View File

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

View File

@ -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 (
<div className={clsx(styles.pagination, className)}>
{Array.from({ length: pagesCount }, (_, i) => (
<SendouButton
key={i}
variant="minimal"
aria-label={`${ariaLabelPrefix} page ${i + 1}`}
onPress={() => setPage(i + 1)}
className={clsx(styles.button, {
[styles.buttonActive]: currentPage === i + 1,
})}
data-testid={testId}
>
{i + 1}
</SendouButton>
))}
</div>
);
}

View File

@ -20,6 +20,7 @@ import { useUser } from "~/features/auth/core/user";
import { useChatContext } from "~/features/chat/useChatContext";
import { FriendMenu } from "~/features/friends/components/FriendMenu";
import { SENDOUQ_ACTIVITY_LABEL } from "~/features/friends/friends-constants";
import { canAccessTrophies } from "~/features/trophies/trophies-utils";
import { useLayoutSize } from "~/hooks/useMainContentWidth";
import { useUnseenFriendRequests } from "~/hooks/useUnseenFriendRequests";
import type { RootLoaderData } from "~/root";
@ -397,25 +398,29 @@ function MenuOverlay({
<nav aria-label={t("front:mobileNav.menu")}>
<ul className={styles.navGrid}>
{navItems.map((item) => (
<li key={item.name}>
<Link
to={`/${item.url}`}
className={styles.navItem}
onClick={onClose}
>
<div className={styles.navItemImage}>
<Image
path={navIconUrl(item.name)}
height={32}
width={32}
alt=""
/>
</div>
<span>{t(`common:pages.${item.name}` as any)}</span>
</Link>
</li>
))}
{navItems
.filter(
(item) => item.name !== "trophies" || canAccessTrophies(user),
)
.map((item) => (
<li key={item.name}>
<Link
to={`/${item.url}`}
className={styles.navItem}
onClick={onClose}
>
<div className={styles.navItemImage}>
<Image
path={navIconUrl(item.name)}
height={32}
width={32}
alt=""
/>
</div>
<span>{t(`common:pages.${item.name}` as any)}</span>
</Link>
</li>
))}
</ul>
</nav>

View File

@ -1,3 +1,24 @@
:root {
--tier-bg-1: hsl(45, 100%, 47%);
--tier-bg-2: hsl(280, 90%, 44%);
--tier-bg-3: hsl(280, 65%, 52%);
--tier-bg-4: hsl(212, 95%, 44%);
--tier-bg-5: hsl(212, 80%, 51%);
--tier-bg-6: hsl(145, 80%, 34%);
--tier-bg-7: hsl(145, 60%, 41%);
--tier-bg-8: hsl(220, 10%, 40%);
--tier-bg-9: hsl(220, 8%, 49%);
--tier-text-1: hsl(45, 100%, 12%);
--tier-text-2: hsl(280, 100%, 94%);
--tier-text-3: hsl(280, 100%, 95%);
--tier-text-4: hsl(212, 100%, 93%);
--tier-text-5: hsl(212, 100%, 95%);
--tier-text-6: hsl(145, 85%, 92%);
--tier-text-7: hsl(145, 75%, 94%);
--tier-text-8: hsl(220, 15%, 92%);
--tier-text-9: hsl(220, 15%, 95%);
}
.pill {
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
@ -11,46 +32,95 @@
}
.tierX {
background-color: hsl(45, 100%, 30%);
color: hsl(45, 100%, 90%);
--_polished-bg: var(--tier-bg-1);
background-color: var(--tier-bg-1);
color: var(--tier-text-1);
}
.tierSPlus {
background-color: hsl(280, 60%, 35%);
color: hsl(280, 80%, 90%);
--_polished-bg: var(--tier-bg-2);
background-color: var(--tier-bg-2);
color: var(--tier-text-2);
}
.tierS {
background-color: hsl(280, 50%, 40%);
color: hsl(280, 70%, 92%);
--_polished-bg: var(--tier-bg-3);
background-color: var(--tier-bg-3);
color: var(--tier-text-3);
}
.tierAPlus {
background-color: hsl(210, 60%, 35%);
color: hsl(210, 80%, 90%);
background-color: var(--tier-bg-4);
color: var(--tier-text-4);
}
.tierA {
background-color: hsl(210, 50%, 40%);
color: hsl(210, 70%, 92%);
background-color: var(--tier-bg-5);
color: var(--tier-text-5);
}
.tierBPlus {
background-color: hsl(140, 45%, 32%);
color: hsl(140, 60%, 90%);
background-color: var(--tier-bg-6);
color: var(--tier-text-6);
}
.tierB {
background-color: hsl(140, 35%, 38%);
color: hsl(140, 50%, 92%);
background-color: var(--tier-bg-7);
color: var(--tier-text-7);
}
.tierCPlus {
background-color: hsl(0, 0%, 35%);
color: hsl(0, 0%, 90%);
background-color: var(--tier-bg-8);
color: var(--tier-text-8);
}
.tierC {
background-color: hsl(0, 0%, 40%);
color: hsl(0, 0%, 92%);
background-color: var(--tier-bg-9);
color: var(--tier-text-9);
}
.polished {
position: relative;
overflow: hidden;
background-image: linear-gradient(
160deg,
hsl(from var(--_polished-bg) calc(h + 2) s calc(l + 14)),
var(--_polished-bg) 50%,
hsl(from var(--_polished-bg) calc(h - 3) s calc(l - 9))
);
&::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
115deg,
transparent 35%,
hsl(0, 0%, 100%, 0.5) 50%,
transparent 65%
);
transform: translateX(-100%);
animation: shine-sweep 6s ease-in-out infinite;
}
}
@keyframes shine-sweep {
0% {
transform: translateX(-100%);
}
12% {
transform: translateX(100%);
}
100% {
transform: translateX(100%);
}
}
@media (prefers-reduced-motion: reduce) {
.polished::after {
animation: none;
visibility: hidden;
}
}

View File

@ -15,6 +15,8 @@ const TIER_STYLE_CLASS: Record<number, string> = {
9: styles.tierC,
};
const POLISHED_TIERS = [1, 2, 3];
export function TierPill({
tier,
isTentative = false,
@ -29,7 +31,9 @@ export function TierPill({
return (
<div
className={clsx(styles.pill, tierClass)}
className={clsx(styles.pill, tierClass, {
[styles.polished]: POLISHED_TIERS.includes(tier),
})}
data-testid={isTentative ? "tentative-tier" : "confirmed-tier"}
title={
isTentative

View File

@ -0,0 +1,122 @@
import clsx from "clsx";
import * as React from "react";
import { ListBoxItem, type SelectProps } from "react-aria-components";
import { useFetcher } from "react-router";
import type { SearchLoaderData } from "~/features/search/routes/search";
import { SearchSelect } from "./SearchSelect";
import searchSelectStyles from "./SearchSelect.module.css";
import selectStyles from "./Select.module.css";
import { useEntitySearch } from "./useEntitySearch";
export type OrganizationSearchResult = Extract<
NonNullable<SearchLoaderData>["results"][number],
{ type: "organization" }
>;
interface OrganizationSearchProps<T extends object>
extends Omit<SelectProps<T>, "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<T>,
ref?: React.Ref<HTMLButtonElement>,
) {
const initialOrganization = useInitialOrganization(initialOrganizationId);
const search = useEntitySearch<OrganizationSearchResult>({
buildUrl: (query) => `/search?q=${query}&type=organizations&limit=6`,
parseResults: (data, query) =>
parseOrganizationResults(data, query, initialOrganization),
initialItem: initialOrganization,
initialSelectedId: initialOrganizationId,
onChange,
});
return (
<SearchSelect
{...rest}
name={name}
label={label}
bottomText={bottomText}
errorText={errorText}
ariaLabel="Organization search"
inputTestId="organization-search-input"
i18nKey="organizationSearch"
search={search}
buttonRef={ref}
renderItem={(item) => <OrganizationItem item={item} />}
/>
);
});
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<SearchLoaderData>();
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 (
<ListBoxItem
id={item.id}
textValue={item.name}
className={({ isFocused, isSelected }) =>
clsx(searchSelectStyles.item, {
[selectStyles.itemFocused]: isFocused,
[selectStyles.itemSelected]: isSelected,
})
}
data-testid="organization-search-item"
>
<div className={searchSelectStyles.itemTextsContainer}>
{item.name}
<div className={searchSelectStyles.itemAdditionalText}>
/{item.slug}
</div>
</div>
</ListBoxItem>
);
}

View File

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

View File

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

View File

@ -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() {
</button>
</Form>
))}
{DEV_LINK_ITEMS.map((item) => (
<Link
key={item.name}
to={item.url}
className={styles.menuItem}
onClick={() => {
setIsOpen(false);
setIsPreviewSuppressed(true);
}}
>
<Image
path={navIconUrl(item.icon)}
alt=""
size={20}
className={styles.menuItemIcon}
/>
{item.name}
</Link>
))}
</div>
</SendouPopover>
{!isOpen && !isPreviewSuppressed ? (
@ -149,6 +173,19 @@ function DevMenu() {
</button>
</Form>
))}
{DEV_LINK_ITEMS.map((item) => (
<Link
key={item.name}
to={item.url}
className={styles.previewIcon}
title={item.name}
aria-label={item.name}
tabIndex={-1}
onClick={() => setIsPreviewSuppressed(true)}
>
<Image path={navIconUrl(item.icon)} alt="" size={20} />
</Link>
))}
</div>
) : null}
</div>
@ -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 (
<div className={styles.menuWrapper}>

View File

@ -59,8 +59,8 @@ export const navItems = [
prefetch: false,
},
{
name: "badges",
url: "badges",
name: "trophies",
url: "trophies",
prefetch: false,
},
{

View File

@ -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<string>();
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<number, number[]>();
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<number, MainWeaponId[]>();
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);
}

File diff suppressed because one or more lines are too long

View File

@ -165,6 +165,51 @@ export interface BadgeOwner {
count: number;
}
export interface Trophy {
id: GeneratedAlways<number>;
name: string;
model: string;
/** Identifies special trophies, null for regular trophies. */
code: Generated<string | null>;
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<number>;
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<number[]>;
favoriteTrophyIds: JSONColumnTypeNullable<number[]>;
hiddenTrophyIds: JSONColumnTypeNullable<number[]>;
id: GeneratedAlways<number>;
inGameName: string | null;
isArtist: Generated<DBBoolean>;
@ -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;

View File

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

View File

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

View File

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

View File

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

View File

@ -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({
</div>
) : null}
{!everythingVisible ? (
<BadgePagination
<DotPagination
pagesCount={pagesCount}
currentPage={currentPage}
setPage={setPage}
ariaLabelPrefix="Badges"
data-testid="badge-pagination-button"
className={styles.pagination}
/>
) : null}
</div>
);
}
interface BadgePaginationProps {
pagesCount: number;
currentPage: number;
setPage: (page: number) => void;
}
function BadgePagination({
pagesCount,
currentPage,
setPage,
}: BadgePaginationProps) {
return (
<div className={styles.pagination}>
{Array.from({ length: pagesCount }, (_, i) => (
<SendouButton
key={i}
variant="minimal"
aria-label={`Badges page ${i + 1}`}
onPress={() => setPage(i + 1)}
className={clsx(styles.paginationButton, {
[styles.paginationButtonActive]: currentPage === i + 1,
})}
data-testid="badge-pagination-button"
>
{i + 1}
</SendouButton>
))}
</div>
);
}

View File

@ -90,7 +90,7 @@ export default function BadgeDetailsPage() {
Edit
</LinkButton>
) : null}
<div className={styles.ownersContainer}>
<div className={clsx(styles.ownersContainer, "scrollbar")}>
<ul className={styles.owners}>
{data.badge.owners.map((owner) => (
<li key={owner.id}>

View File

@ -82,6 +82,15 @@ const withBadgePrizes = (eb: ExpressionBuilder<DB, "CalendarEvent">) => {
).as("badgePrizes");
};
const withTrophy = (eb: ExpressionBuilder<DB, "CalendarEvent">) => {
return jsonObjectFrom(
eb
.selectFrom("Trophy")
.select(["Trophy.id", "Trophy.name", "Trophy.model"])
.whereRef("Trophy.id", "=", "CalendarEvent.trophyId"),
).as("trophy");
};
function tournamentOrganization(organizationId: Expression<number | null>) {
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<Tables["CalendarEventDate"]["startsAt"]>;
badges: Array<Tables["CalendarEventBadge"]["badgeId"]>;
trophyId?: Tables["CalendarEvent"]["trophyId"];
mapPoolMaps?: Array<Pick<Tables["MapPoolMap"], "mode" | "stageId">>;
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")

View File

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

View File

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

View File

@ -0,0 +1,5 @@
.trophyPreview {
background-color: var(--color-bg-high);
border: var(--border-style);
border-radius: var(--radius-field);
}

View File

@ -40,6 +40,7 @@ export interface CalendarEvent extends CommonEvent {
badges: Array<
Pick<Tables["Badge"], "id" | "code" | "displayName" | "hue">
> | null;
trophy: Pick<Tables["Trophy"], "model"> | null;
}
export interface ShowcaseCalendarEvent extends CommonEvent {

View File

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

View File

@ -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 ? (
<div className={clsx(styles.pill, styles.pillRanked)}>
<Trophy />
<TrophyIcon />
</div>
) : null}
{isCalendar && tournament.badges && tournament.badges.length > 0 ? (
<BadgePrizesPill badges={tournament.badges} />
{isCalendar &&
(tournament.trophy ||
(tournament.badges && tournament.badges.length > 0)) ? (
<PrizesPill
badges={tournament.badges}
trophy={tournament.trophy?.model}
/>
) : null}
{isHostedOnSendouInk ? (
<div className={styles.teamCount}>
@ -292,10 +298,12 @@ function ModesPill({ modes }: { modes: NonNullable<CalendarEvent["modes"]> }) {
);
}
function BadgePrizesPill({
function PrizesPill({
badges,
trophy,
}: {
badges: NonNullable<CalendarEvent["badges"]>;
badges: CalendarEvent["badges"];
trophy?: string;
}) {
return (
<SendouPopover
@ -307,18 +315,22 @@ function BadgePrizesPill({
>
<Image
size={16}
path={navIconUrl("badges")}
path={trophy ? navIconUrl("trophies") : navIconUrl("badges")}
alt="Badge prizes"
className={styles.badgeNavIcon}
/>
</SendouButton>
}
>
<BadgeDisplay
badges={badges}
showText={false}
className={styles.badgeDisplay}
/>
{trophy ? (
<Trophy model={trophy} className={styles.trophyPreview} />
) : badges ? (
<BadgeDisplay
badges={badges}
showText={false}
className={styles.badgeDisplay}
/>
) : null}
</SendouPopover>
);
}

View File

@ -22,6 +22,7 @@ function makeEvent(
type: "calendar",
normalizedTeamCount: 0,
badges: [],
trophy: null,
logoUrl: null,
name: "",
url: "",

View File

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

View File

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

View File

@ -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 ? (
<FormField name="badges" options={data.badgeOptions} />
) : null}
{isTournament ? <TrophyField /> : null}
{isTournament ? <FormField name="avatarImgId" /> : null}
{isTournament ? (
<>
@ -331,6 +336,97 @@ function DescriptionField({ isTournament }: { isTournament: boolean }) {
);
}
function TrophyField() {
const { t } = useTranslation("calendar");
const data = useLoaderData<typeof loader>();
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 (
<FormField name="trophyId">
{({ onChange }: CustomFieldRenderProps) => {
const handleChange = (newTrophyId: number | null) => {
onChange(newTrophyId);
if (newTrophyId) {
setValue("badges", []);
}
};
return (
<div className="stack md">
<div>
<label htmlFor={id}>{t("forms.trophy")}</label>
<select
id={id}
value={trophyId ?? ""}
onChange={(e) => {
const value = e.target.value;
handleChange(value === "" ? null : Number(value));
}}
>
<option value="">{t("forms.trophy.placeholder")}</option>
{availableTrophies.map((trophy) => (
<option key={trophy.id} value={trophy.id}>
{trophy.name}
</option>
))}
</select>
</div>
{selectedTrophy ? (
<div className="stack md items-center">
<Trophy
model={selectedTrophy.model}
className={styles.trophyPreview}
/>
<div className="stack horizontal md items-center">
<span>{selectedTrophy.name}</span>
<SendouButton
className="ml-auto"
onPress={() => handleChange(null)}
icon={<Trash />}
variant="minimal-destructive"
aria-label="Remove trophy"
/>
</div>
</div>
) : null}
</div>
);
}}
</FormField>
);
}
function MemberCountFields() {
const { values } = useFormFieldContext();

View File

@ -23,3 +23,15 @@
.componentContent {
width: 100%;
}
.trophyExample {
width: 140px;
}
.trophyExampleSmall {
width: 90px;
}
.trophyExampleLarge {
width: 200px;
}

File diff suppressed because one or more lines are too long

View File

@ -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 (
<Section>
<SectionTitle id={id}>Trophies</SectionTitle>
<TrophyContextProvider>
<div className="stack md">
<ComponentRow label="Interactive (drag to rotate)">
<Trophy
tile
model={EXAMPLE_TROPHY_MODEL}
className={styles.trophyExample}
/>
</ComponentRow>
<ComponentRow label="Preview (static)">
<Trophy
tile
model={EXAMPLE_TROPHY_MODEL}
className={styles.trophyExample}
preview
/>
</ComponentRow>
<ComponentRow label="With Tier">
<div className="stack horizontal sm flex-wrap">
{([1, 4, 9] as const).map((tier) => (
<Trophy
key={tier}
tile
model={EXAMPLE_TROPHY_MODEL}
className={styles.trophyExample}
tier={tier}
preview
/>
))}
</div>
</ComponentRow>
<ComponentRow label="Tentative Tier">
<Trophy
tile
model={EXAMPLE_TROPHY_MODEL}
className={styles.trophyExample}
tentativeTier={2}
preview
/>
</ComponentRow>
<ComponentRow label="Different Sizes">
<div className="stack horizontal sm items-end flex-wrap">
<Trophy
tile
model={EXAMPLE_TROPHY_MODEL}
className={clsx(
styles.trophyExample,
styles.trophyExampleSmall,
)}
preview
/>
<Trophy
tile
model={EXAMPLE_TROPHY_MODEL}
className={styles.trophyExample}
preview
/>
<Trophy
tile
model={EXAMPLE_TROPHY_MODEL}
className={clsx(
styles.trophyExample,
styles.trophyExampleLarge,
)}
preview
/>
</div>
</ComponentRow>
</div>
</TrophyContextProvider>
</Section>
);
}
function TierPillSection({ id }: { id: string }) {
return (
<Section>
<SectionTitle id={id}>Tier Pills</SectionTitle>
<div className="stack md">
<ComponentRow label="All Tiers">
<div className="stack horizontal sm flex-wrap">
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((tier) => (
<TierPill key={tier} tier={tier} />
))}
</div>
</ComponentRow>
<ComponentRow label="Tentative">
<div className="stack horizontal sm flex-wrap">
{[1, 4, 9].map((tier) => (
<TierPill key={tier} tier={tier} isTentative />
))}
</div>
</ComponentRow>
</div>
</Section>
);
}
function GameSelectSection({ id }: { id: string }) {
const [selectedWeapon, setSelectedWeapon] = useState<MainWeaponId | null>(
null,

View File

@ -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")}
</h2>
<div className={styles.tournamentCardsSpacer}>
<div className={clsx(styles.tournamentCardsSpacer, "scrollbar")}>
{data.tournaments.results.map((tournament) => (
<TournamentCard key={tournament.id} tournament={tournament} />
))}
@ -316,9 +317,12 @@ const DISCOVER_EXCLUDED_ITEMS = new Set(["settings", "luti"]);
function DiscoverFeatures() {
const { t } = useTranslation(["front", "common"]);
const data = useLoaderData<typeof loader>();
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 (

View File

@ -22,6 +22,9 @@ const NOTIFICATION_URGENCY: Record<Notification["type"], Urgency> = {
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",

View File

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

View File

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

View File

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

View File

@ -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<unknown>(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(
{},
"",

View File

@ -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<T>(obj: T) {
return compressToBase64(JSON.stringify(obj), { urlSafe: true });
}
export function decompress<T>(compressed: string) {
const json = decompressFromBase64(compressed);
if (json === null) return null;
try {
return JSON.parse(json) as T;
} catch {
return null;
}
}

View File

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

View File

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

View File

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

View File

@ -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<typeof loader>();
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<TournamentBadgeReceivers>([]);
const [trophyReceiverUserIds, setTrophyReceiverUserIds] =
React.useState<Array<number>>(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 (
<SendouDialog
isOpen
@ -46,19 +82,37 @@ export default function TournamentFinalizePage() {
heading={t("tournament:actions.finalize")}
>
<FinalizeForm
error={badgesError}
isAssigningBadges={!isAssignLaterSelected && tournamentHasBadges}
error={error}
isAssigningBadges={
!isAssignBadgesLaterSelected &&
tournamentHasBadges &&
!tournamentHasTrophy
}
>
{tournamentHasBadges ? (
{tournamentHasTrophy && data.trophy && firstPlaceStanding ? (
<>
<input
type="hidden"
name="trophyReceiver"
value={JSON.stringify(trophyReceiver)}
/>
<NewTrophyReceiversSelector
trophy={data.trophy}
firstPlaceStanding={firstPlaceStanding}
trophyReceiverUserIds={trophyReceiverUserIds}
setTrophyReceiverUserIds={setTrophyReceiverUserIds}
/>
</>
) : tournamentHasBadges ? (
<>
<SendouSwitch
isSelected={isAssignLaterSelected}
onChange={setIsAssignLaterSelected}
isSelected={isAssignBadgesLaterSelected}
onChange={setIsAssignBadgesLaterSelected}
data-testid="assign-badges-later-switch"
>
{t("tournament:actions.finalize.assignBadgesLater")}
</SendouSwitch>
{!isAssignLaterSelected ? (
{!isAssignBadgesLaterSelected ? (
<>
<input
type="hidden"
@ -86,7 +140,9 @@ function FinalizeForm({
isAssigningBadges,
}: {
children: React.ReactNode;
error: ReturnType<typeof validateBadgeReceivers>;
error:
| ReturnType<typeof validateBadgeReceivers>
| ReturnType<typeof validateTrophyReceiver>;
isAssigningBadges: boolean;
}) {
const fetcher = useFetcher();
@ -248,3 +304,52 @@ function NewBadgeReceiversSelector({
</div>
);
}
function NewTrophyReceiversSelector({
trophy,
firstPlaceStanding,
trophyReceiverUserIds,
setTrophyReceiverUserIds,
}: {
trophy: NonNullable<FinalizeTournamentLoaderData["trophy"]>;
firstPlaceStanding: FinalizeTournamentLoaderData["standings"][number];
trophyReceiverUserIds: Array<number>;
setTrophyReceiverUserIds: (userIds: Array<number>) => void;
}) {
const handleReceiverSelected = (userId: number) => (isSelected: boolean) => {
if (isSelected) {
setTrophyReceiverUserIds([...trophyReceiverUserIds, userId]);
} else {
setTrophyReceiverUserIds(
trophyReceiverUserIds.filter((id) => id !== userId),
);
}
};
return (
<div className="stack md">
<Trophy model={trophy.model} />
<Divider />
{firstPlaceStanding.members.map((member, i) => {
return (
<div key={member.userId} className="stack sm">
<div className="stack horizontal items-center">
<SendouSwitch
isSelected={trophyReceiverUserIds.includes(member.userId)}
onChange={handleReceiverSelected(member.userId)}
/>
<Avatar user={member} size="xxs" className="mr-2" />
{member.username}
</div>
<div className="stack horizontal sm items-end">
<ParticipationPill setResults={member.setResults} />
</div>
{i !== firstPlaceStanding.members.length - 1 ? (
<Divider className="mt-3" />
) : null}
</div>
);
})}
</div>
);
}

View File

@ -183,6 +183,14 @@ const badgeReceivers = z.array(
}),
);
export type TournamentTrophyReceiver = z.infer<typeof trophyReceiver>;
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()),
});

View File

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

View File

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

View File

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

View File

@ -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<typeof loader> = (args) => {
};
export const handle: SendouRouteHandle = {
i18n: ["badges", "org"],
i18n: ["badges", "org", "trophies"],
breadcrumb: ({ match }) => {
const data = match.loaderData as SerializeFrom<typeof loader> | undefined;
@ -209,7 +221,7 @@ function LogoHeader() {
}
function InfoTabs() {
const { t } = useTranslation(["org"]);
const { t } = useTranslation(["org", "trophies"]);
const data = useLoaderData<typeof loader>();
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 (
<div>
@ -229,11 +243,11 @@ function InfoTabs() {
{t("org:edit.form.members.title")}
</SendouTab>
<SendouTab
id="badges"
isDisabled={!hasBadges}
icon={<Image path={navIconUrl("badges")} alt="" width={16} />}
id="rewards"
isDisabled={!hasRewards}
icon={<Image path={navIconUrl("trophies")} alt="" width={16} />}
>
{t("org:edit.form.badges.title")}
{t("org:edit.form.rewards.title")}
</SendouTab>
{canBanPlayers && data.bannedUsers ? (
<SendouTab
@ -256,8 +270,11 @@ function InfoTabs() {
<SendouTabPanel id="members">
<MembersList />
</SendouTabPanel>
<SendouTabPanel id="badges">
<BadgeDisplay badges={data.organization.badges} />
<SendouTabPanel id="rewards">
<RewardsPanel
badges={data.organization.badges}
trophies={data.trophies}
/>
</SendouTabPanel>
{data.bannedUsers ? (
<SendouTabPanel id="banned-users">
@ -274,6 +291,37 @@ function InfoTabs() {
);
}
function RewardsPanel({
badges,
trophies,
}: {
badges: SerializeFrom<typeof loader>["organization"]["badges"];
trophies: SerializeFrom<typeof loader>["trophies"];
}) {
const { t } = useTranslation(["org", "trophies"]);
return (
<div className="stack sm">
{trophies.length > 0 ? (
<>
<Divider className="mt-2" smallText>
{t("trophies:title")}
</Divider>
<RewardsTrophyGrid trophies={trophies} />
</>
) : null}
{badges.length > 0 ? (
<>
<Divider className="mt-2" smallText>
{t("org:edit.form.badges.title")}
</Divider>
<BadgeDisplay badges={badges} />
</>
) : null}
</div>
);
}
function AdminControls() {
const data = useLoaderData<typeof loader>();
@ -692,3 +740,74 @@ function EventLeaderboardRow({
</div>
);
}
function RewardsTrophyGrid({
trophies,
}: {
trophies: SerializeFrom<typeof loader>["trophies"];
}) {
const visibleCount = useProgressiveRender(trophies.length, "");
const [openTrophy, setOpenTrophy] = React.useState<
SerializeFrom<typeof loader>["trophies"][number] | null
>(null);
return (
<TrophyContextProvider>
<TrophyGrid>
{trophies.map((trophy, i) =>
i < visibleCount ? (
<button
key={trophy.id}
type="button"
className={styles.trophyGridButton}
onClick={() => setOpenTrophy(trophy)}
aria-label={trophy.name}
>
<Trophy
tile
model={trophy.model}
tier={trophy.tier}
tentativeTier={trophy.tentativeTier}
preview
/>
</button>
) : (
<TrophyPlaceholder key={trophy.id} />
),
)}
</TrophyGrid>
{openTrophy ? (
<TrophyShowcaseModal
trophy={openTrophy}
onClose={() => setOpenTrophy(null)}
>
<TrophyModalTournaments
key={openTrophy.id}
trophyId={openTrophy.id}
/>
</TrophyShowcaseModal>
) : null}
</TrophyContextProvider>
);
}
function TrophyModalTournaments({ trophyId }: { trophyId: number }) {
const { t } = useTranslation(["trophies"]);
const fetcher = useFetcher<TrophyTournamentsLoaderData>();
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 (
<div className={styles.trophyModalTournaments}>
<Divider smallText>{t("trophies:details.tournamentHistory")}</Divider>
<TrophyTournamentHistory tournaments={fetcher.data.tournaments} />
</div>
);
}

View File

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

View File

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

View File

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

View File

@ -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<DB, "Trophy">) =>
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<TrophyRecentTournament> },
>({ 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<TrophyRecentTournament>) {
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<DB, "Trophy">) => {
return jsonObjectFrom(
eb
.selectFrom("User")
.select((eb) => commonUserSelect(eb))
.whereRef("User.id", "=", "Trophy.creatorId"),
).as("creator");
};
const withManager = (eb: ExpressionBuilder<DB, "Trophy">) => {
return jsonObjectFrom(
eb
.selectFrom("User")
.select((eb) => commonUserSelect(eb))
.whereRef("User.id", "=", "Trophy.managerId"),
).as("manager");
};
const withOrganization = (eb: ExpressionBuilder<DB, "Trophy">) => {
return jsonObjectFrom(
eb
.selectFrom("TournamentOrganization")
.select(["TournamentOrganization.name", "TournamentOrganization.slug"])
.whereRef("TournamentOrganization.id", "=", "Trophy.organizationId"),
).as("organization");
};
const withOwners = (eb: ExpressionBuilder<DB, "Trophy">) => {
return jsonArrayFrom(
eb
.selectFrom("TrophyOwner")
.innerJoin("User", "TrophyOwner.userId", "User.id")
.select((eb) => [
eb.fn.count<number>("TrophyOwner.trophyId").as("count"),
...commonUserSelect(eb),
])
.whereRef("TrophyOwner.trophyId", "=", "Trophy.id")
.groupBy("User.id")
.orderBy("count", "desc"),
).as("owners");
};
const withSpecialOwners = (eb: ExpressionBuilder<DB, "Trophy">) => {
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<number>("TrophyOwner.trophyId").as("count"),
fn.min<number | null>("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<number>().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<DB>) {
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<DB>) {
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<number, number[]>(
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<DB>;
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<DB, "PendingTrophy">) => {
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<DB, "PendingTrophy">) => {
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<DB, "PendingTrophy">) => {
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<number>().as("count"))
.where("submitterUserId", "=", submitterUserId)
.where("declinedAt", "is", null)
.where((eb) =>
eb(
eb
.selectFrom("PendingTrophyApproval")
.select((eb2) => eb2.fn.countAll<number>().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<number>().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<number>().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();
});
}

View File

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

View File

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

View File

@ -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 (
<Link
to={tournamentPage(tournament.tournamentId)}
className={clsx(styles.row, className)}
>
<img
src={tournament.logoUrl}
alt=""
width={32}
height={32}
className={styles.logo}
/>
<div className="stack xxs">
<span className={styles.name}>
<p>{tournament.name}</p>
{tournament.tier ? (
<TierPill tier={tournament.tier} />
) : tournament.tentativeTier ? (
<TierPill tier={tournament.tentativeTier} isTentative />
) : null}
{isUpcoming ? (
<span className={styles.upcomingPill}>
{t("trophies:details.upcoming")}
</span>
) : null}
</span>
<div className={styles.meta}>
<span className={styles.metaItem}>
<Users className={styles.metaIcon} />
{tournament.teamsCount}
</span>
{tournament.startTime ? (
isUpcoming ? (
<time
className={clsx(styles.metaItem, {
invisible: !isHydrated,
})}
dateTime={databaseTimestampToDate(
tournament.startTime,
).toISOString()}
>
{isHydrated
? formatDistanceToNow(tournament.startTime, {
addSuffix: true,
})
: "Placeholder"}
</time>
) : (
<span className={styles.metaItem}>
{formatter.format(tournament.startTime)}
</span>
)
) : null}
</div>
</div>
</Link>
);
}

View File

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

View File

@ -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 (
<div className="stack md">
{selectedTrophiesInOrder.length > 0 ? (
<TrophyContextProvider>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={selectedTrophiesInOrder.map((trophy) => trophy.id)}
strategy={verticalListSortingStrategy}
>
<ul className={styles.list}>
{selectedTrophiesInOrder.map((trophy) => (
<SortableTrophyItem
key={trophy.id}
trophy={trophy}
onRemove={handleRemove}
/>
))}
</ul>
</SortableContext>
</DndContext>
</TrophyContextProvider>
) : (
<div className="text-lighter text-md font-bold">
{t("common:trophies.selector.none")}
</div>
)}
{options.length === 0 ? (
<div className="text-warning text-xs">
{t("common:trophies.selector.noneAvailable")}
</div>
) : (
<select
onBlur={() => onBlur?.()}
onChange={(e) =>
onChange([...selectedTrophies, Number(e.target.value)])
}
disabled={Boolean(maxCount && selectedTrophies.length >= maxCount)}
data-testid="trophies-selector"
>
<option>{t("common:trophies.selector.select")}</option>
{options
.filter((trophy) => !selectedTrophies.includes(trophy.id))
.map((trophy) => (
<option key={trophy.id} value={trophy.id}>
{trophy.name}
</option>
))}
</select>
)}
</div>
);
}
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 (
<li
ref={setNodeRef}
style={style}
className={clsx(styles.item, { [styles.isDragging]: isDragging })}
{...attributes}
>
<button
type="button"
className={styles.dragHandle}
aria-label="Drag to reorder"
{...listeners}
>
</button>
<div className={styles.preview}>
<Trophy model={trophy.model} preview />
</div>
<span className={styles.name}>{trophy.name}</span>
<SendouButton
variant="minimal-destructive"
size="small"
icon={<Trash />}
aria-label="Remove"
onPress={() => onRemove(trophy.id)}
/>
</li>
);
}

View File

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

View File

@ -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<TrophyCtxValue | undefined>(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<PicoCAD2Context | undefined>();
useEffect(() => {
setContext(getSharedTrophyContext());
}, []);
const value: TrophyCtxValue = context
? { context }
: { context: undefined, isLoading: true };
return <TrophyCtx.Provider value={value}>{children}</TrophyCtx.Provider>;
}
export function TrophyGrid({ children }: { children: React.ReactNode }) {
return <div className={style.grid}>{children}</div>;
}
export function TrophyPlaceholder() {
return <div className={style.placeholder} />;
}
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<PicoCAD2Viewer | null>(null);
const [error, setError] = useState<boolean>(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 ? (
<div className={style.tierPill}>
<TierPill tier={tier} />
</div>
) : tentativeTier ? (
<div className={style.tierPill}>
<TierPill tier={tentativeTier} isTentative />
</div>
) : null;
const cornerPill = pill ? (
<div className={style.cornerPill} data-testid="trophy-corner-pill">
{pill}
</div>
) : null;
if (error || modelState === null) {
return (
<div className={containerClassName} style={containerStyle}>
<div className={clsx(style.trophy, style.error)}>
<Ban size={48} />
</div>
{tierPill}
{cornerPill}
</div>
);
}
if (isLoadingSharedContext) {
return (
<div className={containerClassName} style={containerStyle}>
<div className={style.trophy} />
</div>
);
}
return (
<div className={containerClassName} style={containerStyle}>
<canvas
ref={canvasRef}
className={clsx(style.trophy, {
[style.interactive]: !preview && !disableCameraControls,
})}
/>
{tierPill}
{cornerPill}
</div>
);
}
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;
}

View File

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

View File

@ -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<TrophyItem>;
userId: number;
className?: string;
}
export function TrophyDisplay({
trophies,
userId,
className,
}: TrophyDisplayProps) {
const [openTrophy, setOpenTrophy] = React.useState<TrophyItem | null>(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 (
<TrophyContextProvider>
<div
data-testid="trophy-display"
className={clsx(className, styles.root)}
>
<div className={styles.grid}>
{itemsToDisplay.map((trophy, i) =>
i < visibleCount ? (
<button
key={trophy.id}
type="button"
className={styles.trophyButton}
onClick={() => setOpenTrophy(trophy)}
aria-label={trophy.name}
>
<Trophy
tile
model={trophy.model}
tier={trophy.tier ?? null}
preview={!!openTrophy}
staticOnSoftwareRendering
disableCameraControls
pill={
trophy.count && trophy.count > 1
? `×${trophy.count}`
: undefined
}
/>
</button>
) : (
<TrophyPlaceholder key={trophy.id} />
),
)}
</div>
{!everythingVisible ? (
<DotPagination
pagesCount={pagesCount}
currentPage={currentPage}
setPage={setPage}
ariaLabelPrefix="Trophies"
data-testid="trophy-pagination-button"
/>
) : null}
</div>
{openTrophy ? (
<TrophyModal
trophy={openTrophy}
userId={userId}
onClose={() => setOpenTrophy(null)}
/>
) : null}
</TrophyContextProvider>
);
}
function TrophyModal({
trophy,
userId,
onClose,
}: {
trophy: TrophyItem;
userId: number;
onClose: () => void;
}) {
const { t } = useTranslation(["trophies"]);
const fetcher = useFetcher<TrophyWinsLoaderData>();
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 (
<TrophyShowcaseModal trophy={trophy} onClose={onClose}>
{special ? (
<div>
<Divider />
<p className={styles.specialDescription}>
{special.type === "supporter"
? t("trophies:special.supporter.description")
: t("trophies:special.xp.description", {
value: special.value,
})}
</p>
</div>
) : null}
{data
? data.wins.map((win) => (
<div key={win.tournamentId}>
<Divider />
<TrophyWinDetails win={win} userCards={data.userCards} />
</div>
))
: null}
</TrophyShowcaseModal>
);
}
function TrophyWinDetails({
win,
userCards,
}: {
win: TrophyWinsLoaderData["wins"][number];
userCards: TrophyWinsLoaderData["userCards"];
}) {
return (
<div className={styles.win}>
<TournamentSummaryRow tournament={win} className={styles.winHeader} />
<div className={styles.winDetails}>
{win.members.length > 0 ? (
<div className={styles.winMembers}>
{win.members.map((member) => (
<div key={member.id} className={styles.winMember}>
<UserCard data={userCards.get(member.id)}>
<span className={styles.winMemberUser}>
<Avatar size="xxxs" user={member} />
<span className={styles.winMemberName}>
{member.username}
</span>
</span>
</UserCard>
<span className={styles.winMemberWeapons}>
{member.weapons.map((weaponSplId) => (
<WeaponImage
key={weaponSplId}
weaponSplId={weaponSplId}
variant="badge"
width={24}
height={24}
/>
))}
</span>
<ParticipationPill setResults={member.setResults} />
</div>
))}
</div>
) : null}
</div>
</div>
);
}

View File

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

View File

@ -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 (
<div className={styles.wrapper}>
<div className={clsx(styles.content, className)}>
<Trophy model={model} />
<div className={clsx(styles.details, detailsClassName, "scrollbar")}>
{children}
</div>
</div>
</div>
);
}
export function TrophyShowcaseModal({
trophy,
onClose,
children,
}: {
trophy: { id: number; name: string; model: string };
onClose: () => void;
children?: React.ReactNode;
}) {
const { t } = useTranslation(["trophies"]);
return (
<SendouDialog
isOpen
onClose={onClose}
showHeading={false}
isDismissable
className={styles.modal}
>
<TrophyShowcase model={trophy.model}>
<div className="stack xxs">
<p className={styles.trophyName}>{trophy.name}</p>
<Link to={trophyPage(trophy.id)} className={styles.trophyPageLink}>
{t("trophies:display.viewTrophyPage")}
</Link>
</div>
{children}
</TrophyShowcase>
</SendouDialog>
);
}

View File

@ -0,0 +1,11 @@
.list {
display: flex;
flex-direction: column;
padding: 0;
margin: 0;
gap: var(--s-1);
& > li {
list-style: none;
}
}

View File

@ -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 (
<ul className={styles.list}>
{tournaments.map((tournament) => (
<li key={tournament.tournamentId}>
<TournamentSummaryRow tournament={tournament} />
</li>
))}
</ul>
);
}

View File

@ -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<number> | { r: number; g: number; b: number };
interface RawFace {
vertex_ids: Array<number>;
dbl?: boolean;
prio?: boolean;
}
interface RawGraphNode {
visible: boolean;
children: Array<RawGraphNode>;
mesh?: { faces: Array<RawFace> };
}
interface ModelState {
source: {
graph: RawGraphNode;
texture: {
colors: Array<RawColor>;
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<number>();
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;
}

View File

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

View File

@ -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<typeof loader>;
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<ReturnType<typeof TrophyRepository.allPending>>[number],
) {
return {
...item,
approvals: item.approvals.map(() => ({
userId: null as number | null,
username: null as string | null,
createdAt: 0,
})),
declinedByUserId: null,
declinedByUsername: null,
};
}

View File

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

View File

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

View File

@ -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<typeof loader> | 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<typeof loader>();
const { trophy, tournaments } = data;
const special = parseSpecialTrophyCode(trophy.code);
return (
<TrophyShowcase
model={trophy.model}
className={styles.trophyDetailsContainer}
detailsClassName={styles.trophyDetails}
>
<div className="stack xxs">
<p className={styles.trophyName}>{trophy.name}</p>
{trophy.organization ? (
<p className={styles.trophyMeta}>
<Trans
t={t}
i18nKey="trophies:details.organization"
values={{ name: trophy.organization.name }}
>
Organization:
<Link
to={tournamentOrganizationPage({
organizationSlug: trophy.organization.slug,
})}
>
{trophy.organization.name}
</Link>
</Trans>
</p>
) : null}
{trophy.creator ? (
<p className={styles.trophyMeta}>
<Trans
t={t}
i18nKey="trophies:details.createdBy"
values={{ name: trophy.creator.username }}
>
Created by
<Link to={userPage(trophy.creator)}>
{trophy.creator.username}
</Link>
</Trans>
</p>
) : null}
{trophy.manager ? (
<p className={styles.trophyMeta}>
<Trans
t={t}
i18nKey="trophies:details.managedBy"
values={{ name: trophy.manager.username }}
>
Managed by
<Link to={userPage(trophy.manager)}>
{trophy.manager.username}
</Link>
</Trans>
</p>
) : null}
{special ? (
<p className={styles.trophyMeta}>
{special.type === "supporter"
? t("trophies:special.supporter.description")
: t("trophies:special.xp.description", {
value: special.value,
})}
</p>
) : null}
</div>
{special ? null : (
<div className="stack xs">
<Divider className={styles.divider} smallText>
{t("trophies:details.tournamentHistory")}
</Divider>
{tournaments.length > 0 ? (
<TrophyTournamentHistory tournaments={tournaments} />
) : (
<p className="text-center text-lighter text-xxs">
{t("trophies:details.noTournamentHistory")}
</p>
)}
</div>
)}
<div className="stack xs">
<Divider className={styles.divider} smallText>
{t("trophies:details.owners")}
</Divider>
{trophy.owners.length > 0 ? (
<ul className={styles.owners}>
{trophy.owners.map((owner) => (
<li key={owner.id}>
<Link to={userPage(owner)}>{owner.username}</Link>
<span
className={clsx(styles.ownerCount, {
hidden: owner.count <= 1,
})}
>
×{owner.count}
</span>
</li>
))}
</ul>
) : (
<p className="text-center text-lighter text-xxs">
{t("trophies:details.noOwners")}
</p>
)}
</div>
</TrophyShowcase>
);
}

View File

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

View File

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

View File

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

View File

@ -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<typeof loader>();
return (
<Main halfWidth>
<SendouTabs>
<SendouTabList>
<SendouTab id="upload">{t("trophies:new.tabs.upload")}</SendouTab>
<SendouTab
id="update"
isDisabled={data.editableTrophies.length === 0}
>
{t("trophies:new.tabs.update")}
</SendouTab>
<SendouTab id="pending" number={data.pendingTrophies.length}>
{t("trophies:new.tabs.pending")}
</SendouTab>
<SendouTab id="reviewed">{t("trophies:new.tabs.reviewed")}</SendouTab>
</SendouTabList>
<SendouTabPanel id="upload">
{data.ownUnreviewedCount >= TROPHY_PENDING_PER_USER_LIMIT ? (
<Alert variation="WARNING">
{t("trophies:new.form.limitReached", {
limit: TROPHY_PENDING_PER_USER_LIMIT,
})}
</Alert>
) : (
<TrophyTermsGate>
<NewTrophyForm key={data.ownUnreviewedCount} />
</TrophyTermsGate>
)}
</SendouTabPanel>
<SendouTabPanel id="update">
{data.ownUnreviewedCount >= TROPHY_PENDING_PER_USER_LIMIT ? (
<Alert variation="WARNING">
{t("trophies:new.form.limitReached", {
limit: TROPHY_PENDING_PER_USER_LIMIT,
})}
</Alert>
) : (
<UpdateTrophyTab key={data.ownUnreviewedCount} />
)}
</SendouTabPanel>
<SendouTabPanel id="pending">
<TrophyList items={data.pendingTrophies} listKind="pending" />
</SendouTabPanel>
<SendouTabPanel id="reviewed">
<TrophyList items={data.reviewedTrophies} listKind="reviewed" />
</SendouTabPanel>
</SendouTabs>
</Main>
);
}
function TrophyTermsGate({ children }: { children: React.ReactNode }) {
const { t } = useTranslation(["trophies"]);
const { hasAgreedToTerms, agreeToTerms } = useTrophyTermsAgreement();
if (hasAgreedToTerms) return children;
return (
<div className={styles.terms}>
<h2 className={styles.termsTitle}>{t("trophies:new.terms.title")}</h2>
<p>{t("trophies:new.terms.intro")}</p>
<div>
<p className={styles.termsGroupTitle}>
{t("trophies:new.terms.oneOff.title")}
</p>
<ul className={styles.termsList}>
<li>{t("trophies:new.terms.trustedOrg")}</li>
<li>{t("trophies:new.terms.oneOff.pastTournaments")}</li>
<li>{t("trophies:new.terms.oneOff.projectedTeams")}</li>
<li>{t("trophies:new.terms.oneOff.signedUpTeams")}</li>
</ul>
</div>
<div>
<p className={styles.termsGroupTitle}>
{t("trophies:new.terms.series.title")}
</p>
<ul className={styles.termsList}>
<li>{t("trophies:new.terms.trustedOrg")}</li>
<li>{t("trophies:new.terms.series.consistentTeams")}</li>
</ul>
</div>
<div>
<p className={styles.termsDisclaimer}>
{t("trophies:new.terms.disclaimer")}
</p>
<p>
<Trans t={t} i18nKey="trophies: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
<a
href={SENDOU_INK_DISCORD_URL}
target="_blank"
rel="noopener noreferrer"
>
Discord server
</a>
.
</Trans>
</p>
</div>
<SendouButton className={styles.termsAgreeButton} onPress={agreeToTerms}>
{t("trophies:new.terms.agree")}
</SendouButton>
</div>
);
}
function NewTrophyForm() {
return (
<SendouForm schema={createTrophyFormSchema}>
{({ FormField }) => (
<>
<FormField name="name" />
<FormField name="organizationId">
{({ error, value, onChange }: CustomFieldRenderProps) => (
<OrganizationField
error={error}
value={value as number | null}
onChange={onChange}
/>
)}
</FormField>
<FormField name="model">
{({ name, error, value, onChange }: CustomFieldRenderProps) => (
<ModelField
name={name}
error={error}
value={value as string}
onChange={onChange}
/>
)}
</FormField>
<FormField name="description" />
</>
)}
</SendouForm>
);
}
function UpdateTrophyTab() {
const { t } = useTranslation(["trophies"]);
const data = useLoaderData<typeof loader>();
const [selectedId, setSelectedId] = React.useState<number | null>(null);
const selectedTrophy = data.editableTrophies.find((t) => t.id === selectedId);
return (
<div className={styles.updateContainer}>
<SendouSelect
label={t("trophies:new.update.selectLabel")}
items={data.editableTrophies}
search={{ placeholder: t("trophies:new.update.searchPlaceholder") }}
selectedKey={selectedId}
onSelectionChange={(key) => setSelectedId(key as number)}
>
{(trophy) => (
<SendouSelectItem
key={trophy.id}
id={trophy.id}
textValue={trophy.name}
>
{trophy.name}
</SendouSelectItem>
)}
</SendouSelect>
{selectedTrophy ? (
<UpdateTrophyForm key={selectedTrophy.id} trophy={selectedTrophy} />
) : null}
</div>
);
}
function UpdateTrophyForm({
trophy,
}: {
trophy: NewTrophyLoaderData["editableTrophies"][number];
}) {
const decompressedModel = decompressTrophyModel(trophy.model) ?? "";
return (
<SendouForm
schema={updateTrophyFormSchema}
defaultValues={{
targetTrophyId: trophy.id,
name: trophy.name,
model: decompressedModel,
organizationId: trophy.organizationId,
managerId: trophy.managerId,
description: "",
}}
>
{({ FormField }) => (
<>
<FormField name="name" />
<FormField name="organizationId">
{({ error, value, onChange }: CustomFieldRenderProps) => (
<OrganizationField
error={error}
value={value as number | null}
onChange={onChange}
/>
)}
</FormField>
<FormField name="managerId">
{({ error, value, onChange }: CustomFieldRenderProps) => (
<ManagerField
error={error}
value={value as number | null}
onChange={onChange}
/>
)}
</FormField>
<FormField name="model">
{({ name, error, value, onChange }: CustomFieldRenderProps) => (
<ModelField
name={name}
error={error}
value={value as string}
onChange={onChange}
/>
)}
</FormField>
<FormField name="description" />
</>
)}
</SendouForm>
);
}
function ManagerField({
error,
value,
onChange,
}: {
error?: string;
value: number | null;
onChange: (value: number | null) => void;
}) {
const { t } = useTranslation(["forms"]);
return (
<div>
<Label required>{t("forms:labels.trophyManager")}</Label>
<UserSearch
initialUserId={value ?? undefined}
onChange={(user) => onChange(user?.id ?? null)}
/>
{error ? <FormMessage type="error">{error}</FormMessage> : null}
</div>
);
}
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 (
<div>
<Label htmlFor={name} required>
{t("forms:labels.trophyModel")}
</Label>
<textarea
id={name}
className={styles.modelTextarea}
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
spellCheck={false}
/>
<FormMessage type="info">
{t("forms:bottomTexts.trophyModel")}
<a
href={PICOCAD2_WEB_VIEWER_URL}
target="_blank"
rel="noopener noreferrer"
>
{" "}
PicoCAD2 Web Viewer
</a>
</FormMessage>
{error ? <FormMessage type="error">{error}</FormMessage> : null}
{preview.compressedModel ? (
<TrophyContextProvider>
<div className={styles.previewThemes}>
{(["light", "dark"] as const).map((theme) => (
<div
key={theme}
className={clsx(styles.previewTheme, `${theme}-preview`)}
>
<span className={styles.previewThemeLabel}>
{t(`trophies:new.form.preview.${theme}`)}
</span>
<Trophy
model={preview.compressedModel}
className={styles.trophyPreview}
preview
tier={1}
/>
<Trophy
model={preview.compressedModel}
className={styles.trophyPreview}
/>
</div>
))}
</div>
</TrophyContextProvider>
) : null}
<ModelSpecs analysis={preview.analysis} />
</div>
);
}
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 (
<div className={styles.modelSpecs}>
<div>
<div className={styles.termsGroupTitle}>
{t("trophies:new.specs.required")}
</div>
<ul className={styles.specList}>
<SpecItem status={enforcedStatus(!!analysis?.cameraTargetCentered)}>
{t("trophies:new.specs.cameraTarget")}
</SpecItem>
<SpecItem status={enforcedStatus(!!analysis?.backgroundIsAlpha)}>
{t("trophies:new.specs.background")}
</SpecItem>
<SpecItem>{t("trophies:new.specs.centered")}</SpecItem>
<SpecItem>{t("trophies:new.specs.zoom")}</SpecItem>
<SpecItem>{t("trophies:new.specs.angles")}</SpecItem>
</ul>
</div>
<div>
<div className={styles.termsGroupTitle}>
{t("trophies:new.specs.recommended")}
</div>
<ul className={styles.specList}>
<SpecItem
status={recommendedStatus(
!!analysis &&
analysis.drawCalls <= TROPHY_MODEL_RECOMMENDED_MAX_DRAW_CALLS,
)}
detail={
analysis
? t("trophies:new.specs.currentValue", {
value: analysis.drawCalls,
})
: undefined
}
>
{t("trophies:new.specs.drawCalls", {
value: TROPHY_MODEL_RECOMMENDED_MAX_DRAW_CALLS,
})}
</SpecItem>
<SpecItem
status={recommendedStatus(
!!analysis &&
analysis.polyCount <= TROPHY_MODEL_RECOMMENDED_MAX_POLYS,
)}
detail={
analysis
? t("trophies:new.specs.currentValue", {
value: analysis.polyCount,
})
: undefined
}
>
{t("trophies:new.specs.polys", {
value: TROPHY_MODEL_RECOMMENDED_MAX_POLYS,
})}
</SpecItem>
<SpecItem
status={recommendedStatus(
!!analysis &&
analysis.effectsCount <= TROPHY_MODEL_RECOMMENDED_MAX_EFFECTS,
)}
detail={
analysis
? t("trophies:new.specs.currentValue", {
value: analysis.effectsCount,
})
: undefined
}
>
{t("trophies:new.specs.effects", {
value: TROPHY_MODEL_RECOMMENDED_MAX_EFFECTS,
})}
</SpecItem>
</ul>
</div>
</div>
);
}
function SpecItem({
children,
status,
detail,
}: {
children: React.ReactNode;
status?: "pass" | "fail" | "warn" | null;
detail?: string;
}) {
return (
<li className={styles.specItem}>
{status === "pass" ? (
<Check className={clsx(styles.specIcon, styles.specIconPass)} />
) : status === "fail" ? (
<X className={clsx(styles.specIcon, styles.specIconFail)} />
) : status === "warn" ? (
<TriangleAlert className={clsx(styles.specIcon, styles.specIconWarn)} />
) : (
<Dot className={styles.specIcon} />
)}
<span>
{children}
{detail ? <span className={styles.specDetail}> {detail}</span> : null}
</span>
</li>
);
}
function OrganizationField({
error,
value,
onChange,
}: {
error?: string;
value: number | null;
onChange: (value: number | null) => void;
}) {
const { t } = useTranslation(["forms"]);
return (
<div>
<Label required>{t("forms:labels.trophyOrganization")}</Label>
<OrganizationSearch
initialOrganizationId={value ?? undefined}
onChange={(org) => onChange(org?.id ?? null)}
/>
{error ? <FormMessage type="error">{error}</FormMessage> : null}
</div>
);
}
function TrophyList({
items,
listKind,
}: {
items: NewTrophyLoaderData["pendingTrophies"];
listKind: "pending" | "reviewed";
}) {
const { t } = useTranslation(["trophies"]);
const data = useLoaderData<typeof loader>();
const visibleCount = useProgressiveRender(items.length, listKind);
if (items.length === 0) {
return (
<Alert>
{listKind === "pending"
? t("trophies:new.pending.empty")
: t("trophies:new.reviewed.empty")}
</Alert>
);
}
return (
<TrophyContextProvider>
<div className={styles.pendingList}>
{items.slice(0, visibleCount).map((item) => (
<TrophyListRow
key={item.id}
pending={item}
currentUserId={data.currentUserId}
canReview={data.canReview}
/>
))}
</div>
</TrophyContextProvider>
);
}
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 (
<div className={styles.pendingItem}>
<button
type="button"
className={styles.trophyPreviewButton}
onClick={() => setPreviewOpen(true)}
>
<Trophy model={pending.model} preview />
</button>
<SendouDialog
heading={pending.name}
isOpen={previewOpen}
onClose={() => setPreviewOpen(false)}
showCloseButton
>
<Trophy model={pending.model} className={styles.trophyPreview} />
</SendouDialog>
<div className={styles.pendingMain}>
<div className={styles.pendingHeader}>
{pending.target ? (
<span className={styles.editingBadge}>
{t("trophies:new.update.editing")}
</span>
) : null}
{pending.target &&
pending.submitterUserId !== pending.target.managerId ? (
<span className={styles.notManagerBadge}>
{t("trophies:new.pending.notFromManager")} (
{pending.submitterUsername})
</span>
) : null}
<span className={styles.pendingName}>{pending.name}</span>
<span className={styles.pendingMeta}>
{pending.manager?.discordId ? (
<Link to={userPage({ discordId: pending.manager.discordId })}>
{pending.manager.username}
</Link>
) : pending.submitterDiscordId ? (
<Link to={userPage({ discordId: pending.submitterDiscordId })}>
{pending.submitterUsername}
</Link>
) : (
pending.submitterUsername
)}
{pending.organizationName ? (
<>
{" • "}
{pending.organizationSlug ? (
<Link
to={tournamentOrganizationPage({
organizationSlug: pending.organizationSlug,
})}
>
{pending.organizationName}
</Link>
) : (
pending.organizationName
)}
</>
) : null}
</span>
</div>
{analysis ? (
<div className={styles.pendingSpecs}>
<span
className={clsx({
[styles.pendingSpecsWarn]:
analysis.drawCalls > TROPHY_MODEL_RECOMMENDED_MAX_DRAW_CALLS,
})}
>
{t("trophies:new.specs.stats.drawCalls", {
value: analysis.drawCalls,
})}
</span>
<span
className={clsx({
[styles.pendingSpecsWarn]:
analysis.polyCount > TROPHY_MODEL_RECOMMENDED_MAX_POLYS,
})}
>
{t("trophies:new.specs.stats.polys", {
value: analysis.polyCount,
})}
</span>
<span
className={clsx({
[styles.pendingSpecsWarn]:
analysis.effectsCount > TROPHY_MODEL_RECOMMENDED_MAX_EFFECTS,
})}
>
{t("trophies:new.specs.stats.effects", {
value: analysis.effectsCount,
})}
</span>
{!analysis.cameraTargetCentered ? (
<span className={styles.pendingSpecsError}>
{t("trophies:new.specs.stats.cameraTargetOff")}
</span>
) : null}
{!analysis.backgroundIsAlpha ? (
<span className={styles.pendingSpecsError}>
{t("trophies:new.specs.stats.backgroundNotAlpha")}
</span>
) : null}
</div>
) : null}
{pending.target ? (
<PendingTrophyDiff pending={pending} target={pending.target} />
) : null}
{pending.description ? (
<div className={styles.pendingDescription}>{pending.description}</div>
) : null}
{pending.approvals.length > 0 && !isDeclined ? (
<div className={styles.accepted}>
<p>
{t("trophies:new.pending.approvalProgress", {
current: pending.approvals.length,
required: TROPHY_APPROVALS_REQUIRED,
})}
</p>
<p>
{pending.approvals.some((a) => a.userId === currentUserId)
? `(${pending.approvals.map((a) => a.username).join(", ")})`
: ""}
</p>
</div>
) : null}
{isDeclined ? (
<div className={styles.declined}>
<p>
{pending.declinedByUsername
? t("trophies:new.pending.declinedBy", {
name: pending.declinedByUsername,
})
: t("trophies:new.pending.declined")}
</p>
<div>{pending.declineReason}</div>
</div>
) : null}
<div className={styles.pendingActions}>
{canReview && !isReviewed ? (
<>
<SendouButton
size="small"
onPress={handleApprove}
isDisabled={fetcher.state !== "idle" || alreadyApproved}
>
{alreadyApproved
? t("trophies:new.pending.approved")
: t("trophies:new.pending.approve")}
</SendouButton>
<DeclineButton pendingTrophyId={pending.id} />
<SendouButton
variant="outlined"
size="small"
shape="square"
icon={<Clipboard size={16} />}
onPress={() =>
navigator.clipboard.writeText(
decompressTrophyModel(pending.model ?? "{}") ?? "",
)
}
/>
</>
) : null}
{isOwner || canReview ? (
<SendouButton
variant="minimal-destructive"
size="small"
shape="square"
onPress={handleDelete}
isDisabled={fetcher.state !== "idle"}
icon={<Trash2 size={16} />}
/>
) : null}
</div>
</div>
</div>
);
}
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 (
<>
<SendouButton
variant="outlined-destructive"
size="small"
onPress={() => setIsOpen(true)}
>
{t("trophies:new.pending.decline")}
</SendouButton>
{isOpen ? (
<SendouDialog
heading={t("trophies:new.pending.declineHeading")}
isOpen={isOpen}
onClose={() => setIsOpen(false)}
showCloseButton
>
<Form
method="post"
className={styles.dialogForm}
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData();
formData.append("_action", "DECLINE");
formData.append("pendingTrophyId", String(pendingTrophyId));
formData.append("reason", reason);
fetcher.submit(formData, { method: "post" });
}}
>
<div>
<Label
htmlFor={id}
required
valueLimits={{
current: reason.length,
max: TROPHY_DECLINE_REASON_MAX_LENGTH,
}}
>
{t("trophies:new.pending.declineReason")}
</Label>
<textarea
id={id}
value={reason}
onChange={(e) => setReason(e.target.value)}
maxLength={TROPHY_DECLINE_REASON_MAX_LENGTH}
required
/>
</div>
<SendouButton
type="submit"
variant="destructive"
isDisabled={!reason.trim() || fetcher.state !== "idle"}
>
{t("trophies:new.pending.decline")}
</SendouButton>
</Form>
</SendouDialog>
) : null}
</>
);
}
function PendingTrophyDiff({
pending,
target,
}: {
pending: NewTrophyLoaderData["pendingTrophies"][number];
target: NonNullable<NewTrophyLoaderData["pendingTrophies"][number]["target"]>;
}) {
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 (
<div className={styles.diffGrid}>
<div className={styles.diffHeader}>{t("trophies:new.update.before")}</div>
<div className={styles.diffHeader}>{t("trophies:new.update.after")}</div>
{changedFields.map((field) => (
<React.Fragment key={field.label}>
<div className={styles.diffField}>
<span className={styles.diffLabel}>{field.label}</span>
<span className={clsx(styles.diffValue, styles.diffOld)}>
{field.oldValue}
</span>
</div>
<div className={styles.diffField}>
<span className={styles.diffLabel}>{field.label}</span>
<span className={styles.diffValue}>{field.newValue}</span>
</div>
</React.Fragment>
))}
</div>
);
}

View File

@ -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<typeof loader>();
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 (
<Main>
<div className={styles.mainContent}>
<Outlet />
<div className={styles.trophiesListContainer}>
<Input
icon={<Search />}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
<TrophyGrid>
<TrophyContextProvider>
{filteredTrophies.map((trophy, i) =>
i < visibleCount ? (
<NavLink to={String(trophy.id)} key={trophy.id}>
<Trophy
tile
model={trophy.model}
tier={trophy.tier}
tentativeTier={trophy.tentativeTier}
preview
pill={
hasUpcomingTournamentSoon(
trophy.upcomingTournamentAt,
) ? (
<CalendarClock
size={16}
role="img"
aria-label={t("trophies:details.upcoming")}
/>
) : undefined
}
/>
</NavLink>
) : (
<TrophyPlaceholder key={trophy.id} />
),
)}
</TrophyContextProvider>
</TrophyGrid>
</div>
<p className={styles.badgesLink}>
{t("trophies:lookingForBadges")}{" "}
<Link to={BADGES_PAGE}>{t("trophies:viewBadges")}</Link>
</p>
</div>
</Main>
);
}

View File

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

View File

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

View File

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

View File

@ -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<Role> } | 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<Role> } | null) {
if (!user) return false;
return user.roles.includes("STAFF") || user.roles.includes("QA");
}
export function canEditAnyTrophy(user?: { roles: Array<Role> } | null) {
if (!user) return false;
return user.roles.includes("ADMIN");
}
export function canEditTrophy(
user: { id: number; roles: Array<Role> } | 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<string, string | null>();
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;
}

View File

@ -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<number>) {
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<TablesInsertable["UserWeapon"], "weaponSplId" | "isFavorite">[];
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,

View File

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

View File

@ -25,5 +25,5 @@
}
.participating {
background-color: var(--color-accent);
background-color: var(--color-text-accent);
}

View File

@ -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({
<Markdown>{widget.data.bio}</Markdown>
</article>
);
case "trophies-owned":
return <TrophyDisplay trophies={widget.data} userId={user.id} />;
case "badges-owned":
return (
<BadgeDisplay badges={widget.data} key={`badges-owned-${user.id}`} />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 ? (
<FormField
name="favoriteTrophyIds"
options={trophyOptions}
maxCount={SMALL_TROPHIES_PER_DISPLAY_PAGE}
/>
) : null}
{data.ownedTrophies.length >= 1 ? (
<FormField name="hiddenTrophyIds" options={trophyOptions} />
) : null}
<FormField name="weapons" />
<FormField name="bio" />
{data.discordUniqueName ? (

View File

@ -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() {
<ExtraInfos />
<WeaponPool />
<TopPlacements />
<BadgeDisplay badges={data.user.badges} key={layoutData.user.id} />
{data.trophies.length > 0 ? (
<TrophyDisplay
trophies={data.trophies}
userId={layoutData.user.id}
key={`trophies-${layoutData.user.id}`}
/>
) : null}
<BadgeDisplay
badges={data.user.badges}
key={`badges-${layoutData.user.id}`}
/>
{data.user.bio && <article>{data.user.bio}</article>}
</div>
);

View File

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

View File

@ -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 (
<TrophiesFormField
{...commonProps}
{...formField}
value={value as number[]}
onChange={handleChange as (v: number[]) => void}
options={options as TrophyOption[]}
{...(maxCount !== undefined ? { maxCount } : {})}
/>
);
}
if (formField.type === "stage-select") {
return (
<StageSelectFormField

View File

@ -32,6 +32,7 @@ import type {
SelectOption,
TeamSearchFieldOptions,
TournamentSearchFieldOptions,
TrophyOption,
} from "./types";
export const formRegistry = z.registry<FormField>();
@ -838,6 +839,23 @@ export function badges(
}) as z.ZodArray<typeof id> & FieldWithOptions<BadgeOption[]>;
}
export function trophies(
args: WithTypedTranslationKeys<
Omit<Extract<FormField, { type: "trophies" }>, "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<typeof id> & FieldWithOptions<TrophyOption[]>;
}
export function stageSelect(
args: WithTypedTranslationKeys<
Omit<

View File

@ -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<FormFieldProps<"trophies">, "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 (
<FormFieldWrapper
id={id}
name={name}
label={label}
error={error}
bottomText={bottomText}
>
<TrophiesSelector
options={options}
selectedTrophies={value}
onChange={onChange}
onBlur={onBlur}
maxCount={maxCount}
/>
</FormFieldWrapper>
);
}

View File

@ -163,6 +163,10 @@ interface FormFieldBadges<T extends string> extends FormFieldBase<T> {
maxCount?: number;
}
interface FormFieldTrophies<T extends string> extends FormFieldBase<T> {
maxCount?: number;
}
interface FormFieldSelectDynamic<T extends string> extends FormFieldBase<T> {
clearable: boolean;
searchable?: boolean;
@ -205,6 +209,7 @@ export type FormField<V extends string = string> =
| 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;

View File

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

View File

@ -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<void> {
];
await UserRepository.updatePatronData(patronsWithMods);
await TrophyRepository.syncSpecialTrophies();
}
const MAX_RETRIES = 10;

Some files were not shown because too many files have changed in this diff Show More