From 3df1f9ed190f14b1ff8294b2ddfbb7dc260d30f4 Mon Sep 17 00:00:00 2001
From: hfcRed <101019309+hfcRed@users.noreply.github.com>
Date: Fri, 31 Jul 2026 08:51:49 +0200
Subject: [PATCH] Tournament Trophies (#3245)
---
app/components/DotPagination.module.css | 27 +
app/components/DotPagination.tsx | 38 +
app/components/MobileNav.tsx | 43 +-
app/components/TierPill.module.css | 106 +-
app/components/TierPill.tsx | 6 +-
.../elements/OrganizationSearch.tsx | 122 +++
app/components/elements/SearchSelect.tsx | 4 +
app/components/layout/AnythingAdder.tsx | 10 +
app/components/layout/TopNavMenus.tsx | 47 +-
app/components/layout/nav-items.ts | 4 +-
app/db/seed/index.ts | 648 +++++++++++-
app/db/seed/trophies.json | 5 +
app/db/tables.ts | 53 +
app/features/admin/AdminRepository.server.ts | 8 +-
app/features/admin/admin-constants.ts | 2 +
app/features/api-private/routes/seed.ts | 16 +
.../badges/components/BadgeDisplay.module.css | 24 -
.../badges/components/BadgeDisplay.tsx | 37 +-
app/features/badges/routes/badges.$id.tsx | 2 +-
.../calendar/CalendarRepository.server.ts | 22 +
.../calendar/actions/calendar.new.server.ts | 17 +
app/features/calendar/calendar-new-schemas.ts | 10 +
app/features/calendar/calendar-new.module.css | 5 +
app/features/calendar/calendar-types.ts | 1 +
.../components/TournamentCard.module.css | 5 +
.../calendar/components/TournamentCard.tsx | 36 +-
.../calendar/core/CalendarEvent.test.ts | 1 +
.../calendar/loaders/calendar.new.server.ts | 17 +
.../calendar/loaders/calendar.server.ts | 19 +-
app/features/calendar/routes/calendar.new.tsx | 96 ++
.../components-showcase.module.css | 12 +
.../example-trophy-model.ts | 2 +
.../components-showcase/routes/components.tsx | 118 +++
app/features/front-page/routes/index.tsx | 8 +-
.../notifications/core/notify.server.ts | 3 +
.../notifications/notifications-types.ts | 9 +
.../notifications/notifications-utils.ts | 11 +
app/features/search/routes/search.ts | 14 +-
.../tier-list-maker/hooks/useTierList.ts | 27 +-
.../tier-list-maker/tier-list-maker-utils.ts | 16 +
.../actions/xsearch.player.$id.server.ts | 2 +
.../to.$id.brackets.finalize.server.ts | 107 +-
.../to.$id.brackets.finalize.server.ts | 14 +-
.../routes/to.$id.brackets.finalize.tsx | 129 ++-
.../tournament-bracket-schemas.server.ts | 8 +
.../tournament-bracket-utils.ts | 25 +-
...TournamentOrganizationRepository.server.ts | 20 +
.../loaders/org.$slug.server.ts | 5 +
.../routes/org.$slug.tsx | 137 ++-
.../tournament-organization.module.css | 20 +
.../tournament/TournamentRepository.server.ts | 30 +-
.../trophies/TrophyRepository.server.test.ts | 295 ++++++
.../trophies/TrophyRepository.server.ts | 883 ++++++++++++++++
.../trophies/actions/trophies.new.server.ts | 231 +++++
.../TournamentSummaryRow.module.css | 57 ++
.../components/TournamentSummaryRow.tsx | 99 ++
.../components/TrophiesSelector.module.css | 54 +
.../trophies/components/TrophiesSelector.tsx | 177 ++++
.../trophies/components/Trophy.module.css | 73 ++
app/features/trophies/components/Trophy.tsx | 255 +++++
.../components/TrophyDisplay.module.css | 112 ++
.../trophies/components/TrophyDisplay.tsx | 211 ++++
.../components/TrophyShowcase.module.css | 47 +
.../trophies/components/TrophyShowcase.tsx | 63 ++
.../TrophyTournamentHistory.module.css | 11 +
.../components/TrophyTournamentHistory.tsx | 19 +
app/features/trophies/core/model-analysis.ts | 110 ++
.../trophies/loaders/trophies.$id.server.ts | 27 +
.../trophies/loaders/trophies.new.server.ts | 67 ++
.../trophies/loaders/trophies.server.ts | 28 +
.../routes/trophies.$id.tournaments.ts | 24 +
app/features/trophies/routes/trophies.$id.tsx | 145 +++
.../routes/trophies.$id.wins.$userId.ts | 33 +
.../trophies/routes/trophies.module.css | 64 ++
.../trophies/routes/trophies.new.module.css | 323 ++++++
app/features/trophies/routes/trophies.new.tsx | 966 ++++++++++++++++++
app/features/trophies/routes/trophies.tsx | 112 ++
app/features/trophies/trophies-constants.ts | 25 +
app/features/trophies/trophies-schemas.ts | 106 ++
app/features/trophies/trophies-utils.test.ts | 123 +++
app/features/trophies/trophies-utils.ts | 166 +++
.../user-page/UserRepository.server.ts | 22 +
.../actions/u.$identifier.edit.server.ts | 11 +
.../components/ParticipationPill.module.css | 2 +-
app/features/user-page/components/Widget.tsx | 3 +
.../user-page/core/trophy-sorting.server.ts | 59 ++
.../core/widgets/portfolio-loaders.server.ts | 8 +
.../user-page/core/widgets/portfolio.ts | 1 +
.../loaders/u.$identifier.edit.server.ts | 8 +
.../loaders/u.$identifier.index.server.ts | 8 +
.../routes/u.$identifier.edit.test.ts | 2 +
.../user-page/routes/u.$identifier.edit.tsx | 20 +
.../user-page/routes/u.$identifier.index.tsx | 14 +-
app/features/user-page/user-page-schemas.ts | 9 +
app/form/FormField.tsx | 18 +
app/form/fields.ts | 18 +
app/form/fields/TrophiesFormField.tsx | 43 +
app/form/types.ts | 12 +
app/modules/i18n/resources.server.ts | 32 +
app/modules/patreon/updater.ts | 2 +
app/modules/permissions/mapper.server.ts | 6 +-
app/modules/permissions/types.ts | 1 +
app/modules/permissions/utils.ts | 13 +-
app/routes.ts | 13 +
app/styles/vars.css | 10 +-
app/utils/compression.test.ts | 36 +
app/utils/compression.ts | 42 +
app/utils/i18n.ts | 1 +
app/utils/kysely.server.ts | 28 +
app/utils/urls.ts | 12 +
db-test.sqlite3 | Bin 1474560 -> 1531904 bytes
e2e/global-setup.ts | 59 +-
e2e/global-teardown.ts | 12 +-
e2e/seeds/db-seed-AB_RR.sqlite3 | Bin 10539008 -> 10948608 bytes
e2e/seeds/db-seed-DEFAULT.sqlite3 | Bin 10530816 -> 10919936 bytes
e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 | Bin 10534912 -> 10924032 bytes
e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 | Bin 10514432 -> 10919936 bytes
e2e/seeds/db-seed-NO_SCRIMS.sqlite3 | Bin 10547200 -> 10960896 bytes
e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 | Bin 10534912 -> 10924032 bytes
e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 | Bin 10473472 -> 10854400 bytes
e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 | Bin 10551296 -> 10932224 bytes
e2e/seeds/db-seed-REG_OPEN.sqlite3 | Bin 10493952 -> 10924032 bytes
e2e/seeds/db-seed-SMALL_SOS.sqlite3 | Bin 10485760 -> 10940416 bytes
e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 | Bin 10248192 -> 10657792 bytes
e2e/trophies.spec.ts | 157 +++
knip.ts | 2 +-
locales/da/calendar.json | 2 +
locales/da/common.json | 13 +
locales/da/forms.json | 12 +-
locales/da/org.json | 1 +
locales/da/tournament.json | 2 +
locales/da/trophies.json | 67 ++
locales/da/user.json | 3 +
locales/de/calendar.json | 2 +
locales/de/common.json | 13 +
locales/de/forms.json | 12 +-
locales/de/org.json | 1 +
locales/de/tournament.json | 2 +
locales/de/trophies.json | 67 ++
locales/de/user.json | 3 +
locales/en/calendar.json | 2 +
locales/en/common.json | 13 +
locales/en/forms.json | 12 +-
locales/en/org.json | 1 +
locales/en/tournament.json | 2 +
locales/en/trophies.json | 67 ++
locales/en/user.json | 3 +
locales/es-ES/calendar.json | 2 +
locales/es-ES/common.json | 13 +
locales/es-ES/forms.json | 12 +-
locales/es-ES/org.json | 1 +
locales/es-ES/tournament.json | 2 +
locales/es-ES/trophies.json | 67 ++
locales/es-ES/user.json | 3 +
locales/es-US/calendar.json | 2 +
locales/es-US/common.json | 13 +
locales/es-US/forms.json | 12 +-
locales/es-US/org.json | 1 +
locales/es-US/tournament.json | 2 +
locales/es-US/trophies.json | 67 ++
locales/es-US/user.json | 3 +
locales/fr-CA/calendar.json | 2 +
locales/fr-CA/common.json | 13 +
locales/fr-CA/forms.json | 12 +-
locales/fr-CA/org.json | 1 +
locales/fr-CA/tournament.json | 2 +
locales/fr-CA/trophies.json | 67 ++
locales/fr-CA/user.json | 3 +
locales/fr-EU/calendar.json | 2 +
locales/fr-EU/common.json | 13 +
locales/fr-EU/forms.json | 12 +-
locales/fr-EU/org.json | 1 +
locales/fr-EU/tournament.json | 2 +
locales/fr-EU/trophies.json | 67 ++
locales/fr-EU/user.json | 3 +
locales/he/calendar.json | 2 +
locales/he/common.json | 13 +
locales/he/forms.json | 12 +-
locales/he/org.json | 1 +
locales/he/tournament.json | 2 +
locales/he/trophies.json | 67 ++
locales/he/user.json | 3 +
locales/it/calendar.json | 2 +
locales/it/common.json | 13 +
locales/it/forms.json | 12 +-
locales/it/org.json | 1 +
locales/it/tournament.json | 2 +
locales/it/trophies.json | 67 ++
locales/it/user.json | 3 +
locales/ja/calendar.json | 2 +
locales/ja/common.json | 13 +
locales/ja/forms.json | 12 +-
locales/ja/org.json | 1 +
locales/ja/tournament.json | 2 +
locales/ja/trophies.json | 67 ++
locales/ja/user.json | 3 +
locales/ko/calendar.json | 2 +
locales/ko/common.json | 13 +
locales/ko/forms.json | 12 +-
locales/ko/org.json | 1 +
locales/ko/tournament.json | 2 +
locales/ko/trophies.json | 67 ++
locales/ko/user.json | 3 +
locales/nl/calendar.json | 2 +
locales/nl/common.json | 13 +
locales/nl/forms.json | 12 +-
locales/nl/org.json | 1 +
locales/nl/tournament.json | 2 +
locales/nl/trophies.json | 67 ++
locales/nl/user.json | 3 +
locales/pl/calendar.json | 2 +
locales/pl/common.json | 13 +
locales/pl/forms.json | 12 +-
locales/pl/org.json | 1 +
locales/pl/tournament.json | 2 +
locales/pl/trophies.json | 67 ++
locales/pl/user.json | 3 +
locales/pt-BR/calendar.json | 2 +
locales/pt-BR/common.json | 13 +
locales/pt-BR/forms.json | 12 +-
locales/pt-BR/org.json | 1 +
locales/pt-BR/tournament.json | 2 +
locales/pt-BR/trophies.json | 67 ++
locales/pt-BR/user.json | 3 +
locales/ru/calendar.json | 2 +
locales/ru/common.json | 13 +
locales/ru/forms.json | 12 +-
locales/ru/org.json | 1 +
locales/ru/tournament.json | 2 +
locales/ru/trophies.json | 67 ++
locales/ru/user.json | 3 +
locales/zh/calendar.json | 2 +
locales/zh/common.json | 13 +
locales/zh/forms.json | 12 +-
locales/zh/org.json | 1 +
locales/zh/tournament.json | 2 +
locales/zh/trophies.json | 67 ++
locales/zh/user.json | 3 +
migrations/163-trophies.js | 116 +++
package.json | 4 +-
pnpm-lock.yaml | 46 +-
scripts/benchmark-db/cases.ts | 16 +
scripts/benchmark-db/fixtures.ts | 45 +
vite.config.ts | 10 +-
244 files changed, 9333 insertions(+), 242 deletions(-)
create mode 100644 app/components/DotPagination.module.css
create mode 100644 app/components/DotPagination.tsx
create mode 100644 app/components/elements/OrganizationSearch.tsx
create mode 100644 app/db/seed/trophies.json
create mode 100644 app/features/calendar/calendar-new.module.css
create mode 100644 app/features/components-showcase/example-trophy-model.ts
create mode 100644 app/features/trophies/TrophyRepository.server.test.ts
create mode 100644 app/features/trophies/TrophyRepository.server.ts
create mode 100644 app/features/trophies/actions/trophies.new.server.ts
create mode 100644 app/features/trophies/components/TournamentSummaryRow.module.css
create mode 100644 app/features/trophies/components/TournamentSummaryRow.tsx
create mode 100644 app/features/trophies/components/TrophiesSelector.module.css
create mode 100644 app/features/trophies/components/TrophiesSelector.tsx
create mode 100644 app/features/trophies/components/Trophy.module.css
create mode 100644 app/features/trophies/components/Trophy.tsx
create mode 100644 app/features/trophies/components/TrophyDisplay.module.css
create mode 100644 app/features/trophies/components/TrophyDisplay.tsx
create mode 100644 app/features/trophies/components/TrophyShowcase.module.css
create mode 100644 app/features/trophies/components/TrophyShowcase.tsx
create mode 100644 app/features/trophies/components/TrophyTournamentHistory.module.css
create mode 100644 app/features/trophies/components/TrophyTournamentHistory.tsx
create mode 100644 app/features/trophies/core/model-analysis.ts
create mode 100644 app/features/trophies/loaders/trophies.$id.server.ts
create mode 100644 app/features/trophies/loaders/trophies.new.server.ts
create mode 100644 app/features/trophies/loaders/trophies.server.ts
create mode 100644 app/features/trophies/routes/trophies.$id.tournaments.ts
create mode 100644 app/features/trophies/routes/trophies.$id.tsx
create mode 100644 app/features/trophies/routes/trophies.$id.wins.$userId.ts
create mode 100644 app/features/trophies/routes/trophies.module.css
create mode 100644 app/features/trophies/routes/trophies.new.module.css
create mode 100644 app/features/trophies/routes/trophies.new.tsx
create mode 100644 app/features/trophies/routes/trophies.tsx
create mode 100644 app/features/trophies/trophies-constants.ts
create mode 100644 app/features/trophies/trophies-schemas.ts
create mode 100644 app/features/trophies/trophies-utils.test.ts
create mode 100644 app/features/trophies/trophies-utils.ts
create mode 100644 app/features/user-page/core/trophy-sorting.server.ts
create mode 100644 app/form/fields/TrophiesFormField.tsx
create mode 100644 app/utils/compression.test.ts
create mode 100644 app/utils/compression.ts
create mode 100644 e2e/trophies.spec.ts
create mode 100644 locales/da/trophies.json
create mode 100644 locales/de/trophies.json
create mode 100644 locales/en/trophies.json
create mode 100644 locales/es-ES/trophies.json
create mode 100644 locales/es-US/trophies.json
create mode 100644 locales/fr-CA/trophies.json
create mode 100644 locales/fr-EU/trophies.json
create mode 100644 locales/he/trophies.json
create mode 100644 locales/it/trophies.json
create mode 100644 locales/ja/trophies.json
create mode 100644 locales/ko/trophies.json
create mode 100644 locales/nl/trophies.json
create mode 100644 locales/pl/trophies.json
create mode 100644 locales/pt-BR/trophies.json
create mode 100644 locales/ru/trophies.json
create mode 100644 locales/zh/trophies.json
create mode 100644 migrations/163-trophies.js
diff --git a/app/components/DotPagination.module.css b/app/components/DotPagination.module.css
new file mode 100644
index 000000000..b07544875
--- /dev/null
+++ b/app/components/DotPagination.module.css
@@ -0,0 +1,27 @@
+.pagination {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--s-3);
+ justify-content: center;
+ align-items: center;
+ max-width: 20rem;
+ margin: 0 auto;
+ margin-top: var(--s-2);
+}
+
+.button {
+ background-color: var(--color-bg);
+ border-radius: 100%;
+ padding: var(--s-1);
+ height: 24px;
+ width: 24px;
+ border: var(--border-style);
+ font-size: var(--font-xs);
+ color: var(--color-text-high);
+}
+
+.buttonActive {
+ color: var(--color-text-accent);
+ background-color: var(--color-bg-high);
+ border-color: var(--color-border-high);
+}
diff --git a/app/components/DotPagination.tsx b/app/components/DotPagination.tsx
new file mode 100644
index 000000000..9cc4563a8
--- /dev/null
+++ b/app/components/DotPagination.tsx
@@ -0,0 +1,38 @@
+import { clsx } from "clsx";
+import { SendouButton } from "~/components/elements/Button";
+import styles from "./DotPagination.module.css";
+
+export function DotPagination({
+ pagesCount,
+ currentPage,
+ setPage,
+ ariaLabelPrefix,
+ "data-testid": testId,
+ className,
+}: {
+ pagesCount: number;
+ currentPage: number;
+ setPage: (page: number) => void;
+ ariaLabelPrefix: string;
+ "data-testid"?: string;
+ className?: string;
+}) {
+ return (
+
= {
9: styles.tierC,
};
+const POLISHED_TIERS = [1, 2, 3];
+
export function TierPill({
tier,
isTentative = false,
@@ -29,7 +31,9 @@ export function TierPill({
return (
["results"][number],
+ { type: "organization" }
+>;
+
+interface OrganizationSearchProps
+ extends Omit, "children" | "onChange"> {
+ name?: string;
+ label?: string;
+ bottomText?: string;
+ errorText?: string;
+ initialOrganizationId?: number;
+ onChange?: (organization: OrganizationSearchResult | null) => void;
+}
+
+export const OrganizationSearch = React.forwardRef(function OrganizationSearch<
+ T extends object,
+>(
+ {
+ name,
+ label,
+ bottomText,
+ errorText,
+ initialOrganizationId,
+ onChange,
+ ...rest
+ }: OrganizationSearchProps,
+ ref?: React.Ref,
+) {
+ const initialOrganization = useInitialOrganization(initialOrganizationId);
+
+ const search = useEntitySearch({
+ buildUrl: (query) => `/search?q=${query}&type=organizations&limit=6`,
+ parseResults: (data, query) =>
+ parseOrganizationResults(data, query, initialOrganization),
+ initialItem: initialOrganization,
+ initialSelectedId: initialOrganizationId,
+ onChange,
+ });
+
+ return (
+ }
+ />
+ );
+});
+
+function parseOrganizationResults(
+ data: unknown,
+ query: string,
+ initialOrganization?: OrganizationSearchResult,
+): OrganizationSearchResult[] | null {
+ const searchData = data as SearchLoaderData;
+ if (!searchData || searchData.query !== query) return null;
+ return searchData.results
+ .filter(
+ (result): result is OrganizationSearchResult =>
+ result.type === "organization",
+ )
+ .filter((org) => org.id !== initialOrganization?.id);
+}
+
+function useInitialOrganization(initialOrganizationId?: number) {
+ const fetcher = useFetcher();
+
+ React.useEffect(() => {
+ if (!initialOrganizationId || fetcher.state !== "idle" || fetcher.data) {
+ return;
+ }
+ fetcher.load(
+ `/search?q=${initialOrganizationId}&type=organizations&limit=1`,
+ );
+ }, [initialOrganizationId, fetcher]);
+
+ return fetcher.data?.results.find(
+ (result): result is OrganizationSearchResult =>
+ result.type === "organization",
+ );
+}
+
+function OrganizationItem({ item }: { item: OrganizationSearchResult }) {
+ return (
+
+ clsx(searchSelectStyles.item, {
+ [selectStyles.itemFocused]: isFocused,
+ [selectStyles.itemSelected]: isSelected,
+ })
+ }
+ data-testid="organization-search-item"
+ >
+
+ {item.name}
+
+ /{item.slug}
+
+
+
+ );
+}
diff --git a/app/components/elements/SearchSelect.tsx b/app/components/elements/SearchSelect.tsx
index b152ceee8..bbd1213c1 100644
--- a/app/components/elements/SearchSelect.tsx
+++ b/app/components/elements/SearchSelect.tsx
@@ -22,6 +22,10 @@ import selectStyles from "./Select.module.css";
import type { EntitySearch } from "./useEntitySearch";
const PLACEHOLDER_TEXTS = {
+ organizationSearch: {
+ placeholder: "common:forms.organizationSearch.placeholder",
+ noResults: "common:forms.organizationSearch.noResults",
+ },
teamSearch: {
placeholder: "common:forms.teamSearch.placeholder",
noResults: "common:forms.teamSearch.noResults",
diff --git a/app/components/layout/AnythingAdder.tsx b/app/components/layout/AnythingAdder.tsx
index 4a1861a7a..179bf182e 100644
--- a/app/components/layout/AnythingAdder.tsx
+++ b/app/components/layout/AnythingAdder.tsx
@@ -1,10 +1,12 @@
import { Plus } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useUser } from "~/features/auth/core/user";
+import { canAccessTrophies } from "~/features/trophies/trophies-utils";
import {
CALENDAR_NEW_PAGE,
lfgNewPostPage,
NEW_TEAM_PAGE,
+ NEW_TROPHY_PAGE,
navIconUrl,
newArtPage,
newAssociationsPage,
@@ -97,6 +99,14 @@ export function AnythingAdder({ compact }: { compact?: boolean }) {
imagePath: navIconUrl("plus"),
href: plusSuggestionsNewPage(),
},
+ canAccessTrophies(user)
+ ? {
+ id: "trophy",
+ children: t("header.adder.trophy"),
+ imagePath: navIconUrl("trophies"),
+ href: NEW_TROPHY_PAGE,
+ }
+ : null,
].filter((item) => item !== null);
return (
diff --git a/app/components/layout/TopNavMenus.tsx b/app/components/layout/TopNavMenus.tsx
index 1e0c60101..dd322460b 100644
--- a/app/components/layout/TopNavMenus.tsx
+++ b/app/components/layout/TopNavMenus.tsx
@@ -7,6 +7,7 @@ import { Config } from "~/config";
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import { useUser } from "~/features/auth/core/user";
+import { canAccessTrophies } from "~/features/trophies/trophies-utils";
import {
impersonateUrl,
navIconUrl,
@@ -22,6 +23,10 @@ const DEV_IMPERSONATE_ITEMS = [
{ name: "Logged out", icon: "log_in", action: STOP_IMPERSONATING_URL },
] as const;
+const DEV_LINK_ITEMS = [
+ { name: "Components", icon: "settings", url: "/components" },
+] as const;
+
const NAV_CATEGORIES = [
{
name: "play",
@@ -61,7 +66,7 @@ const NAV_CATEGORIES = [
{ name: "art", url: "art" },
{ name: "articles", url: "a" },
{ name: "vods", url: "vods" },
- { name: "badges", url: "badges" },
+ { name: "trophies", url: "trophies" },
{ name: "links", url: "links" },
{ name: "plus", url: "plus/suggestions" },
],
@@ -125,6 +130,25 @@ function DevMenu() {
))}
+ {DEV_LINK_ITEMS.map((item) => (
+ {
+ setIsOpen(false);
+ setIsPreviewSuppressed(true);
+ }}
+ >
+
+ {item.name}
+
+ ))}
{!isOpen && !isPreviewSuppressed ? (
@@ -149,6 +173,19 @@ function DevMenu() {
))}
+ {DEV_LINK_ITEMS.map((item) => (
+ setIsPreviewSuppressed(true)}
+ >
+
+
+ ))}
) : null}
@@ -167,9 +204,11 @@ function CategoryMenu({
const isStaff = user?.roles.includes("STAFF") ?? false;
const showStaffOnly = isStaff || process.env.NODE_ENV === "development";
- const visibleItems = category.items.filter(
- (item) => !("staffOnly" in item) || showStaffOnly,
- );
+ const visibleItems = category.items.filter((item) => {
+ if ("staffOnly" in item && !showStaffOnly) return false;
+ if (item.name === "trophies" && !canAccessTrophies(user)) return false;
+ return true;
+ });
return (
diff --git a/app/components/layout/nav-items.ts b/app/components/layout/nav-items.ts
index 5f88b604d..fee7c175e 100644
--- a/app/components/layout/nav-items.ts
+++ b/app/components/layout/nav-items.ts
@@ -59,8 +59,8 @@ export const navItems = [
prefetch: false,
},
{
- name: "badges",
- url: "badges",
+ name: "trophies",
+ url: "trophies",
prefetch: false,
},
{
diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts
index 38af3c8e4..d5211e0d4 100644
--- a/app/db/seed/index.ts
+++ b/app/db/seed/index.ts
@@ -34,10 +34,17 @@ import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"
import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
import { PRESET_COLORS } from "~/features/tier-list-maker/tier-list-maker-constants";
+import * as XRankPlacementRepository from "~/features/top-search/XRankPlacementRepository.server";
+import type { TournamentTierNumber } from "~/features/tournament/core/tiering";
import type { TournamentMapPickingStyle } from "~/features/tournament/tournament-constants";
import { clearAllTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server";
import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server";
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
+import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
+import {
+ SUPPORTER_TROPHY_CODE,
+ XP_TROPHY_CODE_PREFIX,
+} from "~/features/trophies/trophies-constants";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { USER_REPORT_CATEGORIES } from "~/features/user-report/user-report-constants";
import * as VodRepository from "~/features/vods/VodRepository.server";
@@ -95,6 +102,7 @@ import {
STAFF_TEST_ID,
} from "./constants";
import placements from "./placements.json";
+import trophies from "./trophies.json";
const SENDOUQ_DEFAULT_MAPS: Record<
ModeShort,
@@ -254,6 +262,13 @@ const basicSeeds = (variation?: SeedVariation | null) => [
splatoonRotations,
variation === "FINALIZED_BRACKET" ? finalizedBracket : undefined,
variation === "AB_RR" ? abDivisionsTournament : undefined,
+ trophiesToDb,
+ trophyOwners,
+ trophyWinsTournament,
+ upcomingTrophyTournament,
+ pendingTrophiesToDb,
+ assignTrophyToTournament,
+ specialTrophies,
];
export async function seed(variation?: SeedVariation | null) {
@@ -606,6 +621,11 @@ async function wipeDB() {
"ModNote",
"Friendship",
"FriendRequest",
+ "PendingTrophyApproval",
+ "PendingTrophy",
+ "TrophyOwner",
+ "SpecialTrophyOwner",
+ "Trophy",
"User",
"PlusSuggestion",
"PlusVote",
@@ -1184,6 +1204,548 @@ async function badgeManagers() {
}
}
+async function trophiesToDb() {
+ for (const [name, model] of Object.entries(trophies)) {
+ await db
+ .insertInto("Trophy")
+ .values({
+ name,
+ model,
+ organizationId: 1,
+ creatorId: ADMIN_ID,
+ managerId: NZAP_TEST_ID,
+ })
+ .execute();
+ }
+}
+
+async function trophyOwners() {
+ const trophyIds = (await db.selectFrom("Trophy").select("id").execute()).map(
+ (row) => row.id,
+ );
+
+ const tournamentIds = (
+ await db.selectFrom("Tournament").select("id").execute()
+ ).map((row) => row.id);
+
+ let userIds = (
+ await db
+ .selectFrom("User")
+ .select("id")
+ .where("id", "not in", [NZAP_TEST_ID, ADMIN_ID])
+ .execute()
+ ).map((row) => row.id);
+
+ const randomTier = () =>
+ faker.helpers.maybe(() => faker.number.int({ min: 1, max: 9 }), {
+ probability: 0.85,
+ }) ?? null;
+
+ const usedCombinations = new Set();
+ const insertOwner = async (
+ trophyId: number,
+ userId: number,
+ tournamentId: number,
+ ) => {
+ const key = `${trophyId}-${userId}-${tournamentId}`;
+ if (usedCombinations.has(key)) return;
+ usedCombinations.add(key);
+ await db
+ .insertInto("TrophyOwner")
+ .values({
+ trophyId,
+ userId,
+ tournamentId,
+ tier: randomTier(),
+ })
+ .execute();
+ };
+
+ for (const trophyId of trophyIds) {
+ userIds = faker.helpers.shuffle(userIds);
+ const ownerCount = faker.number.int({ min: 1, max: 8 });
+
+ for (let i = 0; i < ownerCount; i++) {
+ const userId = userIds.shift()!;
+ const copies = faker.number.int({ min: 1, max: 3 });
+ const shuffledTournaments = faker.helpers.shuffle([...tournamentIds]);
+
+ for (let j = 0; j < copies && j < shuffledTournaments.length; j++) {
+ await insertOwner(trophyId, userId, shuffledTournaments[j]);
+ }
+
+ userIds.push(userId);
+ }
+ }
+
+ for (const trophyId of trophyIds) {
+ const shuffledTournaments = faker.helpers.shuffle([...tournamentIds]);
+ const copies = faker.number.int({ min: 1, max: 3 });
+
+ for (let i = 0; i < copies && i < shuffledTournaments.length; i++) {
+ await insertOwner(trophyId, ADMIN_ID, shuffledTournaments[i]);
+ }
+ }
+}
+
+const TROPHY_TOURNAMENT_ID = 9;
+const TROPHY_EVENT_ID = 209;
+const TROPHY_TEAM_ID_OFFSET = 800;
+// bracket rows use explicit non-positive ids so brackets started at runtime
+// (and tests referencing their seeded match ids) keep autoincrementing from 1
+const TROPHY_STAGE_ID = 0;
+const TROPHY_GROUP_ID = 0;
+const TROPHY_ROUND_ID_OFFSET = -3;
+const TROPHY_MATCH_ID_OFFSET = -7;
+const TROPHY_GAME_RESULT_ID_OFFSET = -14;
+
+async function trophyWinsTournament() {
+ await insertTournamentWithId({
+ id: TROPHY_TOURNAMENT_ID,
+ mapPickingStyle: "AUTO_ALL",
+ settings: JSON.stringify({
+ isRanked: true,
+ bracketProgression: [
+ {
+ type: "single_elimination",
+ name: "Bracket",
+ requiresCheckIn: false,
+ settings: { thirdPlaceMatch: false },
+ },
+ ],
+ }),
+ isFinalized: 1,
+ tier: 3,
+ });
+
+ await insertCalendarEventWithId({
+ id: TROPHY_EVENT_ID,
+ name: "Trophy Cup",
+ description: "Finished tournament with a trophy prize",
+ discordInviteCode: "test",
+ bracketUrl: "https://example.com",
+ authorId: ADMIN_ID,
+ tournamentId: TROPHY_TOURNAMENT_ID,
+ trophyId: 1,
+ });
+
+ await db
+ .insertInto("CalendarEventDate")
+ .values({
+ eventId: TROPHY_EVENT_ID,
+ startsAt: dateToDatabaseTimestamp(
+ new Date(Date.now() - 1000 * 60 * 60 * 24 * 21),
+ ),
+ })
+ .execute();
+
+ const userIds = await userIdsInAscendingOrderById();
+ const teamNames = [
+ "Splat Society",
+ "Ink Theory",
+ "Booyah Brigade",
+ "Chargers Anonymous",
+ "Squid Parts",
+ "Woomy Council",
+ "Roller Coalition",
+ "Last Splash",
+ ];
+ const teamMembers = new Map();
+
+ for (let i = 0; i < 8; i++) {
+ const teamId = TROPHY_TEAM_ID_OFFSET + i + 1;
+
+ await insertTournamentTeamWithId({
+ id: teamId,
+ name: teamNames[i],
+ createdAt: dateToDatabaseTimestamp(new Date()),
+ tournamentId: TROPHY_TOURNAMENT_ID,
+ inviteCode: shortNanoid(),
+ seed: i + 1,
+ });
+
+ await db
+ .insertInto("TournamentTeamCheckIn")
+ .values({
+ tournamentTeamId: teamId,
+ checkedInAt: dateToDatabaseTimestamp(new Date()),
+ })
+ .execute();
+
+ const members: number[] = [];
+ for (let j = 0; j < 4; j++) {
+ const userId = userIds.shift()!;
+ members.push(userId);
+
+ await db
+ .insertInto("TournamentTeamMember")
+ .values({
+ tournamentTeamId: teamId,
+ userId,
+ createdAt: dateToDatabaseTimestamp(new Date()),
+ role: j === 0 ? "OWNER" : "REGULAR",
+ })
+ .execute();
+ }
+ teamMembers.set(teamId, members);
+ }
+
+ // Bracket structure
+ const stageId = TROPHY_STAGE_ID;
+ await insertTournamentStageWithId({
+ id: stageId,
+ tournamentId: TROPHY_TOURNAMENT_ID,
+ name: "Bracket",
+ number: 1,
+ type: "single_elimination",
+ settings: JSON.stringify({ thirdPlaceMatch: false }),
+ });
+
+ const groupId = TROPHY_GROUP_ID;
+ await insertTournamentGroupWithId({ id: groupId, stageId, number: 1 });
+
+ const roundMaps = JSON.stringify({ count: 3, type: "BEST_OF" });
+
+ const roundIds: number[] = [];
+ for (let r = 1; r <= 3; r++) {
+ const roundId = TROPHY_ROUND_ID_OFFSET + r;
+ await insertTournamentRoundWithId({
+ id: roundId,
+ stageId,
+ groupId,
+ number: r,
+ maps: roundMaps,
+ });
+ roundIds.push(roundId);
+ }
+
+ const t = (seed: number) => TROPHY_TEAM_ID_OFFSET + seed;
+
+ // SE 8-team bracket: standard seeding, higher seed always wins
+ const matches = [
+ { round: 0, number: 1, team1: t(1), team2: t(8), winner: t(1) },
+ { round: 0, number: 2, team1: t(4), team2: t(5), winner: t(4) },
+ { round: 0, number: 3, team1: t(2), team2: t(7), winner: t(2) },
+ { round: 0, number: 4, team1: t(3), team2: t(6), winner: t(3) },
+ { round: 1, number: 1, team1: t(1), team2: t(4), winner: t(1) },
+ { round: 1, number: 2, team1: t(2), team2: t(3), winner: t(2) },
+ { round: 2, number: 1, team1: t(1), team2: t(2), winner: t(1) },
+ ];
+
+ const weaponPools = new Map();
+ const weaponPoolFor = (userId: number) => {
+ const existing = weaponPools.get(userId);
+ if (existing) return existing;
+
+ const pool = faker.helpers.arrayElements(canonicalMainWeaponIds, {
+ min: 1,
+ max: 3,
+ });
+ weaponPools.set(userId, pool);
+ return pool;
+ };
+
+ let gameResultId = TROPHY_GAME_RESULT_ID_OFFSET;
+ for (const [matchIndex, m] of matches.entries()) {
+ const matchId = TROPHY_MATCH_ID_OFFSET + matchIndex + 1;
+ await insertTournamentMatchWithId({
+ id: matchId,
+ stageId,
+ groupId,
+ roundId: roundIds[m.round],
+ number: m.number,
+ opponentOne: JSON.stringify({
+ id: m.team1,
+ score: m.winner === m.team1 ? 2 : 0,
+ }),
+ opponentTwo: JSON.stringify({
+ id: m.team2,
+ score: m.winner === m.team2 ? 2 : 0,
+ }),
+ winnerSide: m.winner === m.team1 ? "opponent1" : "opponent2",
+ });
+
+ // 2 game results (2-0 sweep) with weapons reported for every player
+ for (let g = 1; g <= 2; g++) {
+ gameResultId += 1;
+ await insertTournamentMatchGameResultWithId({
+ id: gameResultId,
+ matchId,
+ mode: "SZ",
+ number: g,
+ reporterId: ADMIN_ID,
+ source: "DEFAULT",
+ stageId: 1,
+ winnerTeamId: m.winner,
+ });
+
+ for (const teamId of [m.team1, m.team2]) {
+ for (const userId of teamMembers.get(teamId)!) {
+ await db
+ .insertInto("ReportedWeapon")
+ .values({
+ tournamentMatchId: matchId,
+ mapIndex: g - 1,
+ weaponSplId: faker.helpers.arrayElement(weaponPoolFor(userId)),
+ userId,
+ createdAt: dateToDatabaseTimestamp(new Date()),
+ })
+ .execute();
+ }
+ }
+ }
+ }
+
+ const placements = [
+ { teamSeed: 1, placement: 1, setResults: ["W", "W", "W"] },
+ { teamSeed: 2, placement: 2, setResults: ["W", "W", "L"] },
+ { teamSeed: 3, placement: 3, setResults: ["W", "L"] },
+ { teamSeed: 4, placement: 3, setResults: ["W", "L"] },
+ { teamSeed: 5, placement: 5, setResults: ["L"] },
+ { teamSeed: 6, placement: 5, setResults: ["L"] },
+ { teamSeed: 7, placement: 5, setResults: ["L"] },
+ { teamSeed: 8, placement: 5, setResults: ["L"] },
+ ];
+
+ for (const p of placements) {
+ const teamId = t(p.teamSeed);
+
+ for (const [memberIndex, userId] of teamMembers.get(teamId)!.entries()) {
+ const setResults = p.setResults.map((result) =>
+ memberIndex > 0 && faker.number.float(1) > 0.75 ? null : result,
+ );
+ const spDiff =
+ Math.round(
+ (p.placement === 1
+ ? faker.number.float({ min: 80, max: 220 })
+ : faker.number.float({ min: -120, max: 60 })) * 10,
+ ) / 10;
+
+ await db
+ .insertInto("TournamentResult")
+ .values({
+ tournamentId: TROPHY_TOURNAMENT_ID,
+ tournamentTeamId: teamId,
+ userId,
+ placement: p.placement,
+ participantCount: 8,
+ setResults: JSON.stringify(setResults),
+ spDiff,
+ })
+ .execute();
+ }
+ }
+
+ for (const userId of teamMembers.get(t(1))!) {
+ await db
+ .insertInto("TrophyOwner")
+ .values({
+ trophyId: 1,
+ userId,
+ tournamentId: TROPHY_TOURNAMENT_ID,
+ tier: 3,
+ })
+ .execute();
+ }
+}
+
+const UPCOMING_TROPHY_TOURNAMENT_ID = 10;
+const UPCOMING_TROPHY_EVENT_ID = 210;
+
+async function upcomingTrophyTournament() {
+ await insertTournamentWithId({
+ id: UPCOMING_TROPHY_TOURNAMENT_ID,
+ mapPickingStyle: "AUTO_ALL",
+ settings: JSON.stringify({
+ isRanked: true,
+ bracketProgression: [
+ {
+ type: "single_elimination",
+ name: "Bracket",
+ requiresCheckIn: false,
+ settings: { thirdPlaceMatch: false },
+ },
+ ],
+ }),
+ });
+
+ await insertCalendarEventWithId({
+ id: UPCOMING_TROPHY_EVENT_ID,
+ name: "Trophy Cup 2",
+ description: "Upcoming tournament with a trophy prize",
+ discordInviteCode: "test",
+ bracketUrl: "https://example.com",
+ authorId: ADMIN_ID,
+ tournamentId: UPCOMING_TROPHY_TOURNAMENT_ID,
+ trophyId: 1,
+ });
+
+ await db
+ .insertInto("CalendarEventDate")
+ .values({
+ eventId: UPCOMING_TROPHY_EVENT_ID,
+ startsAt: dateToDatabaseTimestamp(
+ new Date(Date.now() + 1000 * 60 * 60 * 24 * 10),
+ ),
+ })
+ .execute();
+}
+
+async function pendingTrophiesToDb() {
+ const userIds = (
+ await db
+ .selectFrom("User")
+ .select("id")
+ .where("id", "!=", ADMIN_ID)
+ .orderBy(sql`random()`)
+ .limit(10)
+ .execute()
+ ).map((row) => row.id);
+
+ const orgIds = (
+ await db
+ .selectFrom("TournamentOrganization")
+ .select("id")
+ .limit(5)
+ .execute()
+ ).map((row) => row.id);
+
+ const trophyEntries = Object.entries(trophies);
+
+ const insertPending = (values: {
+ name: string;
+ model: string;
+ createdAt: number;
+ declineReason?: string | null;
+ declinedAt?: number | null;
+ declinedByUserId?: number | null;
+ }) =>
+ db
+ .insertInto("PendingTrophy")
+ .values({
+ name: values.name,
+ model: values.model,
+ description: faker.lorem.sentence(),
+ organizationId: faker.helpers.arrayElement(orgIds),
+ submitterUserId: faker.helpers.arrayElement(userIds),
+ createdAt: values.createdAt,
+ declineReason: values.declineReason ?? null,
+ declinedAt: values.declinedAt ?? null,
+ declinedByUserId: values.declinedByUserId ?? null,
+ })
+ .returning("id")
+ .executeTakeFirstOrThrow();
+
+ const insertApproval = (values: {
+ pendingTrophyId: number;
+ userId: number;
+ createdAt: number;
+ }) => db.insertInto("PendingTrophyApproval").values(values).execute();
+
+ const now = Math.floor(Date.now() / 1000);
+
+ // 5 pending
+ for (let i = 0; i < 5; i++) {
+ const [trophyName, model] = trophyEntries[i % trophyEntries.length];
+ await insertPending({
+ name: `Pending ${trophyName} ${i + 1}`,
+ model,
+ createdAt: now - i * 3600,
+ });
+ }
+
+ // 2 with one approval
+ for (let i = 0; i < 2; i++) {
+ const [trophyName, model] = trophyEntries[(i + 5) % trophyEntries.length];
+ const pending = await insertPending({
+ name: `Partial ${trophyName} ${i + 1}`,
+ model,
+ createdAt: now - (i + 5) * 3600,
+ });
+
+ await insertApproval({
+ pendingTrophyId: pending.id,
+ userId: ADMIN_ID,
+ createdAt: now - i * 1800,
+ });
+ }
+
+ // 3 fully approved
+ for (let i = 0; i < 3; i++) {
+ const [trophyName, model] = trophyEntries[(i + 7) % trophyEntries.length];
+ const pending = await insertPending({
+ name: `Accepted ${trophyName} ${i + 1}`,
+ model,
+ createdAt: now - (i + 7) * 3600,
+ });
+
+ await insertApproval({
+ pendingTrophyId: pending.id,
+ userId: ADMIN_ID,
+ createdAt: now - (i + 1) * 1800,
+ });
+ await insertApproval({
+ pendingTrophyId: pending.id,
+ userId: NZAP_TEST_ID,
+ createdAt: now - i * 1800,
+ });
+ }
+
+ // 3 declined
+ for (let i = 0; i < 3; i++) {
+ const [trophyName, model] = trophyEntries[(i + 10) % trophyEntries.length];
+ await insertPending({
+ name: `Declined ${trophyName} ${i + 1}`,
+ model,
+ createdAt: now - (i + 10) * 3600,
+ declineReason: faker.lorem.sentence(),
+ declinedAt: now - i * 1800,
+ declinedByUserId: ADMIN_ID,
+ });
+ }
+}
+
+async function assignTrophyToTournament() {
+ const PICNIC_EVENT_ID = 201;
+ await db
+ .deleteFrom("CalendarEventBadge")
+ .where("eventId", "=", PICNIC_EVENT_ID)
+ .execute();
+ await db
+ .updateTable("CalendarEvent")
+ .set({ trophyId: 1 })
+ .where("id", "=", PICNIC_EVENT_ID)
+ .execute();
+}
+
+async function specialTrophies() {
+ const models = Object.values(trophies);
+
+ const specialTrophyValues = [
+ {
+ name: "Supporter",
+ model: models[0 % models.length],
+ code: SUPPORTER_TROPHY_CODE,
+ },
+ {
+ name: "3000 X Power",
+ model: models[1 % models.length],
+ code: `${XP_TROPHY_CODE_PREFIX}3000`,
+ },
+ {
+ name: "2600 X Power",
+ model: models[2 % models.length],
+ code: `${XP_TROPHY_CODE_PREFIX}2600`,
+ },
+ ];
+
+ for (const trophy of specialTrophyValues) {
+ await db.insertInto("Trophy").values(trophy).execute();
+ }
+
+ await TrophyRepository.syncSpecialTrophies();
+}
+
async function patrons() {
const userIds = (
await db
@@ -2184,8 +2746,8 @@ async function realVideoCast() {
}
// some copy+paste from placements script
-function xRankPlacements() {
- return db.transaction().execute(async (trx) => {
+async function xRankPlacements() {
+ await db.transaction().execute(async (trx) => {
for (const [i, placement] of placements.entries()) {
const userId = () => {
// admin
@@ -2219,6 +2781,8 @@ function xRankPlacements() {
.execute();
}
});
+
+ await XRankPlacementRepository.refreshAllPeakXp();
}
const addUnvalidatedUserSubmittedImage = (url: string, authorId: number) =>
@@ -3381,10 +3945,11 @@ function insertTournamentWithId(values: {
mapPickingStyle: TournamentMapPickingStyle;
settings: string;
isFinalized?: DBBoolean;
+ tier?: TournamentTierNumber | null;
}) {
return sql`
- insert into "Tournament" ("id", "mapPickingStyle", "settings", "isFinalized")
- values (${values.id}, ${values.mapPickingStyle}, ${values.settings}, ${values.isFinalized ?? 0})
+ insert into "Tournament" ("id", "mapPickingStyle", "settings", "isFinalized", "tier")
+ values (${values.id}, ${values.mapPickingStyle}, ${values.settings}, ${values.isFinalized ?? 0}, ${values.tier ?? null})
`.execute(db);
}
@@ -3399,10 +3964,11 @@ function insertCalendarEventWithId(values: {
organizationId?: number | null;
avatarImgId?: number | null;
tags?: string | null;
+ trophyId?: number | null;
}) {
return sql`
- insert into "CalendarEvent" ("id", "name", "description", "discordInviteCode", "bracketUrl", "authorId", "tournamentId", "organizationId", "avatarImgId", "tags")
- values (${values.id}, ${values.name}, ${values.description}, ${values.discordInviteCode}, ${values.bracketUrl}, ${values.authorId}, ${values.tournamentId ?? null}, ${values.organizationId ?? null}, ${values.avatarImgId ?? null}, ${values.tags ?? null})
+ insert into "CalendarEvent" ("id", "name", "description", "discordInviteCode", "bracketUrl", "authorId", "tournamentId", "organizationId", "avatarImgId", "tags", "trophyId")
+ values (${values.id}, ${values.name}, ${values.description}, ${values.discordInviteCode}, ${values.bracketUrl}, ${values.authorId}, ${values.tournamentId ?? null}, ${values.organizationId ?? null}, ${values.avatarImgId ?? null}, ${values.tags ?? null}, ${values.trophyId ?? null})
`.execute(db);
}
@@ -3419,3 +3985,73 @@ function insertTournamentTeamWithId(values: {
values (${values.id}, ${values.name}, ${values.createdAt}, ${values.tournamentId}, ${values.inviteCode}, ${values.seed})
`.execute(db);
}
+
+function insertTournamentStageWithId(values: {
+ id: number;
+ tournamentId: number;
+ name: string;
+ number: number;
+ type: string;
+ settings: string;
+}) {
+ return sql`
+ insert into "TournamentStage" ("id", "tournamentId", "name", "number", "type", "settings")
+ values (${values.id}, ${values.tournamentId}, ${values.name}, ${values.number}, ${values.type}, ${values.settings})
+ `.execute(db);
+}
+
+function insertTournamentGroupWithId(values: {
+ id: number;
+ stageId: number;
+ number: number;
+}) {
+ return sql`
+ insert into "TournamentGroup" ("id", "stageId", "number")
+ values (${values.id}, ${values.stageId}, ${values.number})
+ `.execute(db);
+}
+
+function insertTournamentRoundWithId(values: {
+ id: number;
+ stageId: number;
+ groupId: number;
+ number: number;
+ maps: string;
+}) {
+ return sql`
+ insert into "TournamentRound" ("id", "stageId", "groupId", "number", "maps")
+ values (${values.id}, ${values.stageId}, ${values.groupId}, ${values.number}, ${values.maps})
+ `.execute(db);
+}
+
+function insertTournamentMatchWithId(values: {
+ id: number;
+ stageId: number;
+ groupId: number;
+ roundId: number;
+ number: number;
+ opponentOne: string;
+ opponentTwo: string;
+ winnerSide: "opponent1" | "opponent2";
+}) {
+ return sql`
+ insert into "TournamentMatch" ("id", "stageId", "groupId", "roundId", "number", "opponentOne", "opponentTwo", "winnerSide")
+ values (${values.id}, ${values.stageId}, ${values.groupId}, ${values.roundId}, ${values.number}, ${values.opponentOne}, ${values.opponentTwo}, ${values.winnerSide})
+ `.execute(db);
+}
+
+function insertTournamentMatchGameResultWithId(values: {
+ id: number;
+ matchId: number;
+ mode: string;
+ number: number;
+ reporterId: number;
+ source: string;
+ stageId: number;
+ winnerTeamId: number;
+}) {
+ return sql`
+ insert into "TournamentMatchGameResult" ("id", "matchId", "mode", "number", "reporterId", "source", "stageId", "winnerTeamId")
+ values (${values.id}, ${values.matchId}, ${values.mode}, ${values.number}, ${values.reporterId}, ${values.source}, ${values.stageId}, ${values.winnerTeamId})
+ `.execute(db);
+}
diff --git a/app/db/seed/trophies.json b/app/db/seed/trophies.json
new file mode 100644
index 000000000..76272c258
--- /dev/null
+++ b/app/db/seed/trophies.json
@@ -0,0 +1,5 @@
+{
+ "Wellstring Wednesday": "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",
+ "Gun With a Really Long Name For No Reason": "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",
+ "Chris P. Bacon": "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"
+}
diff --git a/app/db/tables.ts b/app/db/tables.ts
index 7a1111bd0..2b9bb82c3 100644
--- a/app/db/tables.ts
+++ b/app/db/tables.ts
@@ -165,6 +165,51 @@ export interface BadgeOwner {
count: number;
}
+export interface Trophy {
+ id: GeneratedAlways;
+ name: string;
+ model: string;
+ /** Identifies special trophies, null for regular trophies. */
+ code: Generated;
+ organizationId: number | null;
+ creatorId: number | null;
+ managerId: number | null;
+}
+
+export interface TrophyOwner {
+ trophyId: number;
+ userId: number;
+ tournamentId: number;
+ tier: number | null;
+}
+
+export interface SpecialTrophyOwner {
+ trophyId: number;
+ userId: number;
+ createdAt: number;
+}
+
+export interface PendingTrophy {
+ id: GeneratedAlways;
+ name: string;
+ model: string;
+ description: string;
+ organizationId: number | null;
+ submitterUserId: number;
+ createdAt: number;
+ declineReason: string | null;
+ declinedAt: number | null;
+ declinedByUserId: number | null;
+ targetTrophyId: number | null;
+ managerId: number | null;
+}
+
+export interface PendingTrophyApproval {
+ pendingTrophyId: number;
+ userId: number;
+ createdAt: number;
+}
+
export interface Build {
clothesGearSplId: number | null;
description: string | null;
@@ -222,6 +267,7 @@ export interface CalendarEvent {
tournamentId: number | null;
organizationId: number | null;
avatarImgId: number | null;
+ trophyId: number | null;
}
export interface CalendarEventBadge {
@@ -807,6 +853,8 @@ export interface User {
discordUniqueName: string | null;
/** User's favorite badges they want to show on the front page of the badge display. Index = 0 big badge. */
favoriteBadgeIds: JSONColumnTypeNullable;
+ favoriteTrophyIds: JSONColumnTypeNullable;
+ hiddenTrophyIds: JSONColumnTypeNullable;
id: GeneratedAlways;
inGameName: string | null;
isArtist: Generated;
@@ -1245,6 +1293,11 @@ export interface DB {
TournamentOrganizationBannedUser: TournamentOrganizationBannedUser;
TournamentStreamer: TournamentStreamer;
TournamentMatchVod: TournamentMatchVod;
+ Trophy: Trophy;
+ TrophyOwner: TrophyOwner;
+ SpecialTrophyOwner: SpecialTrophyOwner;
+ PendingTrophy: PendingTrophy;
+ PendingTrophyApproval: PendingTrophyApproval;
TrustRelationship: TrustRelationship;
Friendship: Friendship;
FriendRequest: FriendRequest;
diff --git a/app/features/admin/AdminRepository.server.ts b/app/features/admin/AdminRepository.server.ts
index 9104a94f6..29718712b 100644
--- a/app/features/admin/AdminRepository.server.ts
+++ b/app/features/admin/AdminRepository.server.ts
@@ -5,6 +5,7 @@ import { actorId } from "~/features/auth/core/user.server";
import * as BadgeRepository from "~/features/badges/BadgeRepository.server";
import * as BuildRepository from "~/features/builds/BuildRepository.server";
import * as XRankPlacementRepository from "~/features/top-search/XRankPlacementRepository.server";
+import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import invariant from "~/utils/invariant";
@@ -283,18 +284,19 @@ export async function linkUserAndPlayer({
.execute();
await BadgeRepository.syncXPBadges();
+ await TrophyRepository.syncSpecialTrophies();
await BuildRepository.recalculateAllSortValues(userId);
await XRankPlacementRepository.refreshTenStarWeapons(userId);
}
-export function forcePatron(args: {
+export async function forcePatron(args: {
id: number;
patronTier: Tables["User"]["patronTier"];
patronStartedAt: Date;
patronExpiresAt: Date;
}) {
- return db
+ await db
.updateTable("User")
.set({
patronTier: args.patronTier,
@@ -303,6 +305,8 @@ export function forcePatron(args: {
})
.where("User.id", "=", args.id)
.execute();
+
+ await TrophyRepository.syncSpecialTrophies();
}
export async function findAllBannedUsers() {
diff --git a/app/features/admin/admin-constants.ts b/app/features/admin/admin-constants.ts
index 3a72e5175..a8526ca30 100644
--- a/app/features/admin/admin-constants.ts
+++ b/app/features/admin/admin-constants.ts
@@ -7,6 +7,8 @@ export const ADMIN_ID = process.env.NODE_ENV === "test" ? 1 : 274;
export const STAFF_IDS = [11329, 9719, 9342, 20774, 23094];
// hfcRed
export const DEV_IDS = [27883];
+// hfcRed Dreamy Cafy
+export const QA_IDS: number[] = [27883, 38781, 10654];
export const STAFF_DISCORD_IDS = [
"138757634500067328",
diff --git a/app/features/api-private/routes/seed.ts b/app/features/api-private/routes/seed.ts
index 7a3cdd5e3..5f2699675 100644
--- a/app/features/api-private/routes/seed.ts
+++ b/app/features/api-private/routes/seed.ts
@@ -63,6 +63,8 @@ export const action: ActionFunction = async ({ request }) => {
};
const REG_OPEN_TOURNAMENT_IDS = [1, 3];
+const FINISHED_IN_THE_PAST_EVENT_IDS = [209];
+const UPCOMING_EVENT_IDS = [210];
const SEED_REFERENCE_TIMESTAMP = 1767440151;
@@ -76,6 +78,9 @@ async function adjustSeedDatesToCurrent(variation: SeedVariation) {
1000,
);
const now = Math.floor(Date.now() / 1000);
+ const tenDaysFromNow = Math.floor(
+ (Date.now() + 1000 * 60 * 60 * 24 * 10) / 1000,
+ );
const tournamentEventIds = await db
.selectFrom("CalendarEvent")
@@ -85,6 +90,17 @@ async function adjustSeedDatesToCurrent(variation: SeedVariation) {
.execute();
for (const { id, tournamentId } of tournamentEventIds) {
+ if (FINISHED_IN_THE_PAST_EVENT_IDS.includes(id)) continue;
+
+ if (UPCOMING_EVENT_IDS.includes(id)) {
+ await db
+ .updateTable("CalendarEventDate")
+ .set({ startsAt: tenDaysFromNow })
+ .where("eventId", "=", id)
+ .execute();
+ continue;
+ }
+
const isRegOpen =
variation === "REG_OPEN" &&
REG_OPEN_TOURNAMENT_IDS.includes(tournamentId);
diff --git a/app/features/badges/components/BadgeDisplay.module.css b/app/features/badges/components/BadgeDisplay.module.css
index d9e3fd3dc..733c40b7c 100644
--- a/app/features/badges/components/BadgeDisplay.module.css
+++ b/app/features/badges/components/BadgeDisplay.module.css
@@ -46,29 +46,5 @@
}
.pagination {
- display: flex;
- flex-wrap: wrap;
- gap: var(--s-3);
- justify-content: center;
- align-items: center;
- max-width: 20rem;
- margin: 0 auto;
margin-block-start: var(--s-2);
}
-
-.paginationButton {
- background-color: var(--color-bg);
- border-radius: 100%;
- padding: var(--s-1);
- height: 24px;
- width: 24px;
- border: var(--border-style);
- font-size: var(--font-xs);
- color: var(--color-text-high);
-}
-
-.paginationButtonActive {
- color: var(--color-text-accent);
- background-color: var(--color-bg-high);
- border-color: var(--color-border-high);
-}
diff --git a/app/features/badges/components/BadgeDisplay.tsx b/app/features/badges/components/BadgeDisplay.tsx
index 3d9b5cc7c..73852d11b 100644
--- a/app/features/badges/components/BadgeDisplay.tsx
+++ b/app/features/badges/components/BadgeDisplay.tsx
@@ -3,6 +3,7 @@ import { Trash } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Badge } from "~/components/Badge";
+import { DotPagination } from "~/components/DotPagination";
import { SendouButton } from "~/components/elements/Button";
import type { Tables } from "~/db/tables";
import { BADGE } from "~/features/badges/badges-constants";
@@ -108,43 +109,15 @@ export function BadgeDisplay({
) : null}
{!everythingVisible ? (
-
) : null}
);
}
-
-interface BadgePaginationProps {
- pagesCount: number;
- currentPage: number;
- setPage: (page: number) => void;
-}
-
-function BadgePagination({
- pagesCount,
- currentPage,
- setPage,
-}: BadgePaginationProps) {
- return (
-
- {Array.from({ length: pagesCount }, (_, i) => (
- setPage(i + 1)}
- className={clsx(styles.paginationButton, {
- [styles.paginationButtonActive]: currentPage === i + 1,
- })}
- data-testid="badge-pagination-button"
- >
- {i + 1}
-
- ))}
-
- );
-}
diff --git a/app/features/badges/routes/badges.$id.tsx b/app/features/badges/routes/badges.$id.tsx
index 90cacf51d..13afd4cdd 100644
--- a/app/features/badges/routes/badges.$id.tsx
+++ b/app/features/badges/routes/badges.$id.tsx
@@ -90,7 +90,7 @@ export default function BadgeDetailsPage() {
Edit
) : null}
-
+
{data.badge.owners.map((owner) => (
diff --git a/app/features/calendar/CalendarRepository.server.ts b/app/features/calendar/CalendarRepository.server.ts
index a8734df17..eb0be7211 100644
--- a/app/features/calendar/CalendarRepository.server.ts
+++ b/app/features/calendar/CalendarRepository.server.ts
@@ -82,6 +82,15 @@ const withBadgePrizes = (eb: ExpressionBuilder) => {
).as("badgePrizes");
};
+const withTrophy = (eb: ExpressionBuilder) => {
+ return jsonObjectFrom(
+ eb
+ .selectFrom("Trophy")
+ .select(["Trophy.id", "Trophy.name", "Trophy.model"])
+ .whereRef("Trophy.id", "=", "CalendarEvent.trophyId"),
+ ).as("trophy");
+};
+
function tournamentOrganization(organizationId: Expression) {
return jsonObjectFrom(
db
@@ -200,6 +209,12 @@ function findAllBetweenTwoTimestampsQuery({
)
.orderBy("Badge.id", "asc"),
).as("badges"),
+ jsonObjectFrom(
+ eb
+ .selectFrom("Trophy")
+ .select(["Trophy.model"])
+ .whereRef("Trophy.id", "=", "CalendarEvent.trophyId"),
+ ).as("trophy"),
])
.where("CalendarEvent.hidden", "=", 0)
.where(
@@ -256,6 +271,7 @@ function findAllBetweenTwoTimestampsMapped(
? modesIncluded(row.mapPickingStyle, row.toSetMapPool)
: null,
badges: row.badges,
+ trophy: row.trophy,
logoUrl: row.logoUrl,
startsAt: row.normalizedStartsAt,
isRanked: row.tournamentSettings
@@ -289,10 +305,12 @@ export async function findById(
includeMapPool = false,
includeTieBreakerMapPool = false,
includeBadgePrizes = false,
+ includeTrophy = false,
}: {
includeMapPool?: boolean;
includeTieBreakerMapPool?: boolean;
includeBadgePrizes?: boolean;
+ includeTrophy?: boolean;
} = {},
) {
const [firstRow, ...rest] = await db
@@ -300,6 +318,7 @@ export async function findById(
.$if(includeMapPool, (qb) => qb.select(withMapPool))
.$if(includeTieBreakerMapPool, (qb) => qb.select(withTieBreakerMapPool))
.$if(includeBadgePrizes, (qb) => qb.select(withBadgePrizes))
+ .$if(includeTrophy, (qb) => qb.select(withTrophy))
.innerJoin(
"CalendarEventDate",
"CalendarEvent.id",
@@ -407,6 +426,7 @@ type CreateArgs = Pick<
> & {
startTimes: Array;
badges: Array;
+ trophyId?: Tables["CalendarEvent"]["trophyId"];
mapPoolMaps?: Array>;
isFullTournament: boolean;
mapPickingStyle: Tables["Tournament"]["mapPickingStyle"];
@@ -521,6 +541,7 @@ export async function insert(args: CreateArgs) {
organizationId: args.organizationId,
hidden: args.parentTournamentId || args.isTest || args.isDraft ? 1 : 0,
tournamentId,
+ trophyId: args.trophyId ?? null,
})
.returning("id")
.executeTakeFirstOrThrow();
@@ -586,6 +607,7 @@ export async function update(args: UpdateArgs) {
bracketUrl: args.bracketUrl,
avatarImgId: args.avatarImgId ?? avatarImgId,
organizationId: args.organizationId,
+ trophyId: args.trophyId ?? null,
})
.where("id", "=", args.eventId)
.returning("tournamentId")
diff --git a/app/features/calendar/actions/calendar.new.server.ts b/app/features/calendar/actions/calendar.new.server.ts
index fae255ed6..8f0a94c1b 100644
--- a/app/features/calendar/actions/calendar.new.server.ts
+++ b/app/features/calendar/actions/calendar.new.server.ts
@@ -10,6 +10,8 @@ import {
clearTournamentDataCache,
tournamentFromDB,
} from "~/features/tournament-bracket/core/Tournament.server";
+import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
+import { canAccessTrophies } from "~/features/trophies/trophies-utils";
import { parseFormDataWithImages } from "~/form/parse.server";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import { requireRole } from "~/modules/permissions/guards.server";
@@ -60,6 +62,20 @@ export const action: ActionFunction = async ({ request }) => {
);
}
+ if (data.trophyId) {
+ if (!canAccessTrophies(user)) {
+ errorToast("Trophies are not released yet");
+ }
+
+ const trophyOrganizationId = await TrophyRepository.findOrganizationIdById(
+ data.trophyId,
+ );
+ if (trophyOrganizationId !== organizationId) {
+ errorToast("Trophy does not belong to the selected organization");
+ }
+ data.badges = [];
+ }
+
const managedBadges = await BadgeRepository.findManagedByUserId(user.id);
const dates =
@@ -86,6 +102,7 @@ export const action: ActionFunction = async ({ request }) => {
badges: data.badges.filter((badge) =>
managedBadges.some((mb) => mb.id === badge),
),
+ trophyId: data.trophyId ?? null,
// resolved by parseFormDataWithImages from the `image()` field
avatarImgId: data.avatarImgId ?? undefined,
toToolsEnabled: Number(data.toToolsEnabled),
diff --git a/app/features/calendar/calendar-new-schemas.ts b/app/features/calendar/calendar-new-schemas.ts
index 4b332e0c5..21b3699be 100644
--- a/app/features/calendar/calendar-new-schemas.ts
+++ b/app/features/calendar/calendar-new-schemas.ts
@@ -19,6 +19,7 @@ import {
toggle,
} from "~/form/fields";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
+import { id } from "~/utils/zod";
import { CALENDAR_EVENT, REG_CLOSES_AT_OPTIONS } from "./calendar-constants";
import { bracketProgressionSchema } from "./calendar-schemas";
import { calendarEventMaxDate, calendarEventMinDate } from "./calendar-utils";
@@ -82,6 +83,7 @@ export const calendarNewBaseSchema = z.object({
})),
}),
badges: badges({ label: "labels.badges", maxCount: 50 }),
+ trophyId: customField({ initialValue: null }, id.nullish()),
avatarImgId: image({
label: "labels.logo",
bottomText: "bottomTexts.avatarValidation",
@@ -212,6 +214,14 @@ export function calendarNewSyncRefine(
}
}
+ if (data.trophyId && data.badges.length > 0) {
+ ctx.addIssue({
+ path: ["badges"],
+ code: z.ZodIssueCode.custom,
+ message: "forms:errors.trophyWithBadges",
+ });
+ }
+
if (
data.toToolsEnabled &&
data.minMembersPerTeam === "4" &&
diff --git a/app/features/calendar/calendar-new.module.css b/app/features/calendar/calendar-new.module.css
new file mode 100644
index 000000000..336dfe9b7
--- /dev/null
+++ b/app/features/calendar/calendar-new.module.css
@@ -0,0 +1,5 @@
+.trophyPreview {
+ background-color: var(--color-bg-high);
+ border: var(--border-style);
+ border-radius: var(--radius-field);
+}
diff --git a/app/features/calendar/calendar-types.ts b/app/features/calendar/calendar-types.ts
index 4783a4e86..152e7b7d4 100644
--- a/app/features/calendar/calendar-types.ts
+++ b/app/features/calendar/calendar-types.ts
@@ -40,6 +40,7 @@ export interface CalendarEvent extends CommonEvent {
badges: Array<
Pick
> | null;
+ trophy: Pick | null;
}
export interface ShowcaseCalendarEvent extends CommonEvent {
diff --git a/app/features/calendar/components/TournamentCard.module.css b/app/features/calendar/components/TournamentCard.module.css
index c969cfaf7..9a5f43a3c 100644
--- a/app/features/calendar/components/TournamentCard.module.css
+++ b/app/features/calendar/components/TournamentCard.module.css
@@ -195,3 +195,8 @@
padding: 0 var(--s-1-5);
height: var(--selector-size);
}
+
+.trophyPreview {
+ background-color: var(--color-bg-high);
+ border-radius: var(--radius-box);
+}
diff --git a/app/features/calendar/components/TournamentCard.tsx b/app/features/calendar/components/TournamentCard.tsx
index b11400c8e..c602d8018 100644
--- a/app/features/calendar/components/TournamentCard.tsx
+++ b/app/features/calendar/components/TournamentCard.tsx
@@ -1,5 +1,5 @@
import clsx from "clsx";
-import { ShieldMinus, Trophy, Users } from "lucide-react";
+import { ShieldMinus, Trophy as TrophyIcon, Users } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { SendouButton } from "~/components/elements/Button";
@@ -9,6 +9,7 @@ import { Image, ModeImage } from "~/components/Image";
import { LocaleTime } from "~/components/LocaleTime";
import { TierPill } from "~/components/TierPill";
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
+import { Trophy } from "~/features/trophies/components/Trophy";
import { useFormatDistanceToNow } from "~/hooks/intl/useFormatDistanceToNow";
import { useHydrated } from "~/hooks/useHydrated";
import { useSpoilerFree } from "~/hooks/useSpoilerFree";
@@ -142,11 +143,16 @@ export function TournamentCard({
>
{tournament.isRanked ? (
-
+
) : null}
- {isCalendar && tournament.badges && tournament.badges.length > 0 ? (
-
+ {isCalendar &&
+ (tournament.trophy ||
+ (tournament.badges && tournament.badges.length > 0)) ? (
+
) : null}
{isHostedOnSendouInk ? (
@@ -292,10 +298,12 @@ function ModesPill({ modes }: { modes: NonNullable
}) {
);
}
-function BadgePrizesPill({
+function PrizesPill({
badges,
+ trophy,
}: {
- badges: NonNullable;
+ badges: CalendarEvent["badges"];
+ trophy?: string;
}) {
return (
}
>
-
+ {trophy ? (
+
+ ) : badges ? (
+
+ ) : null}
);
}
diff --git a/app/features/calendar/core/CalendarEvent.test.ts b/app/features/calendar/core/CalendarEvent.test.ts
index c18a41198..8220ad7da 100644
--- a/app/features/calendar/core/CalendarEvent.test.ts
+++ b/app/features/calendar/core/CalendarEvent.test.ts
@@ -22,6 +22,7 @@ function makeEvent(
type: "calendar",
normalizedTeamCount: 0,
badges: [],
+ trophy: null,
logoUrl: null,
name: "",
url: "",
diff --git a/app/features/calendar/loaders/calendar.new.server.ts b/app/features/calendar/loaders/calendar.new.server.ts
index ac8980549..ac9f61a56 100644
--- a/app/features/calendar/loaders/calendar.new.server.ts
+++ b/app/features/calendar/loaders/calendar.new.server.ts
@@ -7,6 +7,8 @@ import * as CalendarRepository from "~/features/calendar/CalendarRepository.serv
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import { tournamentData } from "~/features/tournament-bracket/core/Tournament.server";
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
+import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
+import { canAccessTrophies } from "~/features/trophies/trophies-utils";
import { requireRole } from "~/modules/permissions/guards.server";
import { tournamentBracketsPage } from "~/utils/urls";
import { canEditCalendarEvent } from "../calendar-utils";
@@ -23,6 +25,7 @@ export const loader = async ({ url }: LoaderFunctionArgs) => {
includeMapPool: true,
includeTieBreakerMapPool: true,
includeBadgePrizes: true,
+ includeTrophy: true,
});
if (!event) return;
@@ -84,12 +87,25 @@ export const loader = async ({ url }: LoaderFunctionArgs) => {
? await eventWithTournament("copyEventId")
: undefined;
+ const validOrganizationIds = organizations.flatMap((org) =>
+ typeof org === "string" ? [] : [org.id],
+ );
+
+ const trophies = canAccessTrophies(user)
+ ? await TrophyRepository.findByOrganizationIds(validOrganizationIds)
+ : [];
+
const eventToCopy = eventToCopyRaw
? {
...eventToCopyRaw,
badgePrizes: eventToCopyRaw.badgePrizes?.filter((badge) =>
managedBadges.some((mb) => mb.id === badge.id),
),
+ trophy: eventToCopyRaw.trophy
+ ? trophies.some((t) => t.id === eventToCopyRaw.trophy?.id)
+ ? eventToCopyRaw.trophy
+ : null
+ : null,
}
: undefined;
@@ -120,6 +136,7 @@ export const loader = async ({ url }: LoaderFunctionArgs) => {
? await CalendarRepository.findRecentTournamentsByAuthorId(user.id)
: undefined,
organizations,
+ trophies,
};
};
diff --git a/app/features/calendar/loaders/calendar.server.ts b/app/features/calendar/loaders/calendar.server.ts
index 17415f2a5..d3f9d394d 100644
--- a/app/features/calendar/loaders/calendar.server.ts
+++ b/app/features/calendar/loaders/calendar.server.ts
@@ -7,6 +7,7 @@ import {
calendarFiltersSearchParamsObject,
calendarFiltersSearchParamsSchema,
} from "~/features/calendar/calendar-schemas";
+import { canAccessTrophies } from "~/features/trophies/trophies-utils";
import type { SerializeFrom } from "~/utils/remix";
import { parseSafeSearchParams, parseSearchParams } from "~/utils/remix.server";
import { dayMonthYear } from "~/utils/zod";
@@ -38,8 +39,24 @@ export const loader = async (args: LoaderFunctionArgs) => {
const filters = resolveFilters(args.request, user?.preferences);
const filtered = CalendarEvent.applyFilters(events, filters);
+ const eventTimes = canAccessTrophies(user)
+ ? filtered
+ : filtered.map((time) => ({
+ ...time,
+ events: {
+ shown: time.events.shown.map((event) => ({
+ ...event,
+ trophy: null,
+ })),
+ hidden: time.events.hidden.map((event) => ({
+ ...event,
+ trophy: null,
+ })),
+ },
+ }));
+
return {
- eventTimes: filtered,
+ eventTimes,
dateViewed: parsed.success ? parsed.data : undefined,
filters,
};
diff --git a/app/features/calendar/routes/calendar.new.tsx b/app/features/calendar/routes/calendar.new.tsx
index 9256e3f33..c4da05626 100644
--- a/app/features/calendar/routes/calendar.new.tsx
+++ b/app/features/calendar/routes/calendar.new.tsx
@@ -1,3 +1,4 @@
+import { Trash } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
@@ -13,6 +14,7 @@ import { SubmitButton } from "~/components/SubmitButton";
import type { Tables } from "~/db/tables";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import * as Progression from "~/features/tournament-bracket/core/Progression";
+import { Trophy } from "~/features/trophies/components/Trophy";
import { type CustomFieldRenderProps, FormField } from "~/form/FormField";
import { existingImage } from "~/form/image-field";
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
@@ -26,6 +28,7 @@ import type { SendouRouteHandle } from "~/utils/remix.server";
import { CREATING_TOURNAMENT_DOC_LINK, FAQ_PAGE } from "~/utils/urls";
import { action } from "../actions/calendar.new.server";
import type { RegClosesAtOption } from "../calendar-constants";
+import styles from "../calendar-new.module.css";
import { calendarNewBaseSchema } from "../calendar-new-schemas";
import { datesToRegClosesAt } from "../calendar-utils";
import { BracketProgressionSelector } from "../components/BracketProgressionSelector";
@@ -194,6 +197,7 @@ function useDefaultValues() {
discordInviteCode: baseEvent?.discordInviteCode ?? "",
tags: baseEvent?.tags ?? [],
badges: baseEvent?.badgePrizes?.map((b) => b.id) ?? [],
+ trophyId: baseEvent?.trophy?.id ?? null,
avatarImgId: existingImage(
baseEvent?.avatarImgId,
baseEvent?.tournament?.ctx.logoUrl,
@@ -291,6 +295,7 @@ function CalendarNewFields() {
{data.badgeOptions.length > 0 ? (
) : null}
+ {isTournament ? : null}
{isTournament ? : null}
{isTournament ? (
<>
@@ -331,6 +336,97 @@ function DescriptionField({ isTournament }: { isTournament: boolean }) {
);
}
+function TrophyField() {
+ const { t } = useTranslation("calendar");
+ const data = useLoaderData();
+ const { values, setValue } = useFormFieldContext();
+ const id = React.useId();
+
+ const organizationId = values.organizationId
+ ? Number(values.organizationId)
+ : null;
+ const trophyId = typeof values.trophyId === "number" ? values.trophyId : null;
+ const badgeCount = (values.badges as number[]).length;
+
+ // clear the trophy when the selected organization or badges make it invalid
+ React.useEffect(() => {
+ if (!trophyId) return;
+ const trophyStillValid =
+ badgeCount === 0 &&
+ data.trophies.some(
+ (trophy) =>
+ trophy.id === trophyId && trophy.organizationId === organizationId,
+ );
+ if (!trophyStillValid) {
+ setValue("trophyId", null);
+ }
+ }, [trophyId, badgeCount, organizationId, data.trophies, setValue]);
+
+ const availableTrophies = organizationId
+ ? data.trophies.filter((trophy) => trophy.organizationId === organizationId)
+ : [];
+
+ if (availableTrophies.length === 0 && trophyId === null) return null;
+
+ const selectedTrophy = trophyId
+ ? data.trophies.find((trophy) => trophy.id === trophyId)
+ : null;
+
+ return (
+
+ {({ onChange }: CustomFieldRenderProps) => {
+ const handleChange = (newTrophyId: number | null) => {
+ onChange(newTrophyId);
+ if (newTrophyId) {
+ setValue("badges", []);
+ }
+ };
+
+ return (
+
+
+ {t("forms.trophy")}
+ {
+ const value = e.target.value;
+ handleChange(value === "" ? null : Number(value));
+ }}
+ >
+ {t("forms.trophy.placeholder")}
+ {availableTrophies.map((trophy) => (
+
+ {trophy.name}
+
+ ))}
+
+
+ {selectedTrophy ? (
+
+
+
+ {selectedTrophy.name}
+ handleChange(null)}
+ icon={ }
+ variant="minimal-destructive"
+ aria-label="Remove trophy"
+ />
+
+
+ ) : null}
+
+ );
+ }}
+
+ );
+}
+
function MemberCountFields() {
const { values } = useFormFieldContext();
diff --git a/app/features/components-showcase/components-showcase.module.css b/app/features/components-showcase/components-showcase.module.css
index 271f9e30c..ada31b8d2 100644
--- a/app/features/components-showcase/components-showcase.module.css
+++ b/app/features/components-showcase/components-showcase.module.css
@@ -23,3 +23,15 @@
.componentContent {
width: 100%;
}
+
+.trophyExample {
+ width: 140px;
+}
+
+.trophyExampleSmall {
+ width: 90px;
+}
+
+.trophyExampleLarge {
+ width: 200px;
+}
diff --git a/app/features/components-showcase/example-trophy-model.ts b/app/features/components-showcase/example-trophy-model.ts
new file mode 100644
index 000000000..8fadb2227
--- /dev/null
+++ b/app/features/components-showcase/example-trophy-model.ts
@@ -0,0 +1,2 @@
+export const EXAMPLE_TROPHY_MODEL =
+ "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";
diff --git a/app/features/components-showcase/routes/components.tsx b/app/features/components-showcase/routes/components.tsx
index a989b7621..fa0a26048 100644
--- a/app/features/components-showcase/routes/components.tsx
+++ b/app/features/components-showcase/routes/components.tsx
@@ -1,4 +1,5 @@
import { parseDate } from "@internationalized/date";
+import clsx from "clsx";
import { Check, Plus, Search, SquarePen, Trash } from "lucide-react";
import { useState } from "react";
import { Ability } from "~/components/Ability";
@@ -48,7 +49,12 @@ import { StageSelect } from "~/components/StageSelect";
import { SubmitButton } from "~/components/SubmitButton";
import { SubNav, SubNavLink } from "~/components/SubNav";
import { Table } from "~/components/Table";
+import { TierPill } from "~/components/TierPill";
import { WeaponSelect } from "~/components/WeaponSelect";
+import {
+ Trophy,
+ TrophyContextProvider,
+} from "~/features/trophies/components/Trophy";
import { UserCard } from "~/features/user-card/components/UserCard";
import type { UserCardData } from "~/features/user-card/user-card-types";
import type { CustomFieldRenderProps } from "~/form/FormField";
@@ -56,6 +62,7 @@ import { SendouForm } from "~/form/SendouForm";
import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types";
import type { SendouRouteHandle } from "~/utils/remix.server";
import styles from "../components-showcase.module.css";
+import { EXAMPLE_TROPHY_MODEL } from "../example-trophy-model";
import { formFieldsShowcaseSchema } from "../form-examples-schema";
export const handle: SendouRouteHandle = {
@@ -102,6 +109,8 @@ export const SECTIONS = [
{ title: "Flags", id: "flags", component: FlagSection },
{ title: "Placements", id: "placements", component: PlacementSection },
{ title: "Badges", id: "badges", component: BadgeSection },
+ { title: "Trophies", id: "trophies", component: TrophySection },
+ { title: "Tier Pills", id: "tier-pills", component: TierPillSection },
{ title: "Game Selects", id: "game-selects", component: GameSelectSection },
{ title: "Form Fields", id: "form-fields", component: FormFieldsSection },
{ title: "Miscellaneous", id: "miscellaneous", component: MiscSection },
@@ -2048,6 +2057,115 @@ function BadgeSection({ id }: { id: string }) {
);
}
+function TrophySection({ id }: { id: string }) {
+ return (
+
+ Trophies
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {([1, 4, 9] as const).map((tier) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function TierPillSection({ id }: { id: string }) {
+ return (
+
+ Tier Pills
+
+
+
+
+ {[1, 2, 3, 4, 5, 6, 7, 8, 9].map((tier) => (
+
+ ))}
+
+
+
+
+
+ {[1, 4, 9].map((tier) => (
+
+ ))}
+
+
+
+
+ );
+}
+
function GameSelectSection({ id }: { id: string }) {
const [selectedWeapon, setSelectedWeapon] = useState(
null,
diff --git a/app/features/front-page/routes/index.tsx b/app/features/front-page/routes/index.tsx
index 59918949a..900024895 100644
--- a/app/features/front-page/routes/index.tsx
+++ b/app/features/front-page/routes/index.tsx
@@ -21,6 +21,7 @@ import { PWAInstallBanner } from "~/features/front-page/components/PWAInstallBan
import { SplatoonRotations } from "~/features/front-page/components/SplatoonRotations";
import type * as Changelog from "~/features/front-page/core/Changelog.server";
import * as Seasons from "~/features/mmr/core/Seasons";
+import { canAccessTrophies } from "~/features/trophies/trophies-utils";
import styles from "~/styles/front.module.css";
import { databaseTimestampToDate } from "~/utils/dates";
import type { SendouRouteHandle } from "~/utils/remix.server";
@@ -263,7 +264,7 @@ function ResultHighlights() {
>
{t("front:showcase.results")}
-
+
{data.tournaments.results.map((tournament) => (
))}
@@ -316,9 +317,12 @@ const DISCOVER_EXCLUDED_ITEMS = new Set(["settings", "luti"]);
function DiscoverFeatures() {
const { t } = useTranslation(["front", "common"]);
const data = useLoaderData
();
+ const user = useUser();
const filteredNavItems = navItems.filter(
- (item) => !DISCOVER_EXCLUDED_ITEMS.has(item.name),
+ (item) =>
+ !DISCOVER_EXCLUDED_ITEMS.has(item.name) &&
+ (item.name !== "trophies" || canAccessTrophies(user)),
);
return (
diff --git a/app/features/notifications/core/notify.server.ts b/app/features/notifications/core/notify.server.ts
index 30aa4dc9f..ef9b48395 100644
--- a/app/features/notifications/core/notify.server.ts
+++ b/app/features/notifications/core/notify.server.ts
@@ -22,6 +22,9 @@ const NOTIFICATION_URGENCY: Record = {
TO_LIKE_ACCEPTED: "high",
BADGE_ADDED: "normal",
BADGE_MANAGER_ADDED: "normal",
+ TROPHY_SUBMITTED: "normal",
+ TROPHY_SUBMISSION_ACCEPTED: "normal",
+ TROPHY_SUBMISSION_DECLINED: "normal",
PLUS_VOTING_STARTED: "normal",
PLUS_SUGGESTION_ADDED: "normal",
TAGGED_TO_ART: "normal",
diff --git a/app/features/notifications/notifications-types.ts b/app/features/notifications/notifications-types.ts
index 7c47aa34f..0cd12fdc5 100644
--- a/app/features/notifications/notifications-types.ts
+++ b/app/features/notifications/notifications-types.ts
@@ -49,6 +49,15 @@ export type Notification =
"BADGE_MANAGER_ADDED",
{ badgeName: string; badgeId: number }
>
+ | NotificationItem<
+ "TROPHY_SUBMITTED",
+ { trophyName: string; submitterUsername: string }
+ >
+ | NotificationItem<
+ "TROPHY_SUBMISSION_ACCEPTED",
+ { trophyName: string; trophyId: number }
+ >
+ | NotificationItem<"TROPHY_SUBMISSION_DECLINED", { trophyName: string }>
| NotificationItem<
"PLUS_VOTING_STARTED",
{
diff --git a/app/features/notifications/notifications-utils.ts b/app/features/notifications/notifications-utils.ts
index d2336245b..19f4a15ba 100644
--- a/app/features/notifications/notifications-utils.ts
+++ b/app/features/notifications/notifications-utils.ts
@@ -2,6 +2,7 @@ import { assertUnreachable } from "~/utils/types";
import {
badgePage,
FRIENDS_PAGE,
+ NEW_TROPHY_PAGE,
PLUS_VOTING_PAGE,
plusSuggestionPage,
SENDOUQ_PAGE,
@@ -12,6 +13,7 @@ import {
tournamentRegisterPage,
tournamentSubsPage,
tournamentTeamPage,
+ trophyPage,
userArtPage,
userEditProfilePage,
} from "~/utils/urls";
@@ -22,6 +24,10 @@ export const notificationNavIcon = (type: Notification["type"]) => {
case "BADGE_ADDED":
case "BADGE_MANAGER_ADDED":
return "badges";
+ case "TROPHY_SUBMITTED":
+ case "TROPHY_SUBMISSION_ACCEPTED":
+ case "TROPHY_SUBMISSION_DECLINED":
+ return "trophies";
case "PLUS_SUGGESTION_ADDED":
case "PLUS_VOTING_STARTED":
return "plus";
@@ -58,6 +64,11 @@ export const notificationLink = (notification: Notification) => {
return badgePage(notification.meta.badgeId);
case "BADGE_MANAGER_ADDED":
return badgePage(notification.meta.badgeId);
+ case "TROPHY_SUBMITTED":
+ case "TROPHY_SUBMISSION_DECLINED":
+ return NEW_TROPHY_PAGE;
+ case "TROPHY_SUBMISSION_ACCEPTED":
+ return trophyPage(notification.meta.trophyId);
case "PLUS_SUGGESTION_ADDED":
return plusSuggestionPage({ tier: notification.meta.tier });
case "PLUS_VOTING_STARTED":
diff --git a/app/features/search/routes/search.ts b/app/features/search/routes/search.ts
index b1fb9473d..7872cdf20 100644
--- a/app/features/search/routes/search.ts
+++ b/app/features/search/routes/search.ts
@@ -76,10 +76,16 @@ async function searchByType({
}));
}
case "organizations": {
- const orgs = await TournamentOrganizationRepository.searchByName({
- query,
- limit,
- });
+ const numericQuery = /^\d+$/.test(query) ? Number(query) : null;
+ const orgs = numericQuery
+ ? await TournamentOrganizationRepository.findOneById(numericQuery).then(
+ (o) => (o ? [o] : []),
+ )
+ : await TournamentOrganizationRepository.searchByName({
+ query,
+ limit,
+ });
+
return orgs.map((o) => ({
type: "organization" as const,
id: o.id,
diff --git a/app/features/tier-list-maker/hooks/useTierList.ts b/app/features/tier-list-maker/hooks/useTierList.ts
index 4db4dcaa9..70cdc63f1 100644
--- a/app/features/tier-list-maker/hooks/useTierList.ts
+++ b/app/features/tier-list-maker/hooks/useTierList.ts
@@ -4,7 +4,6 @@ import type {
DragStartEvent,
} from "@dnd-kit/core";
import { arrayMove } from "@dnd-kit/sortable";
-import JSONCrush from "jsoncrush";
import * as React from "react";
import { useSearchParams } from "react-router";
import { z } from "zod";
@@ -31,7 +30,12 @@ import {
tierListItemTypeSchema,
tierListStateSerializedSchema,
} from "../tier-list-maker-schemas";
-import { addItemToTier, getNextNthForItem } from "../tier-list-maker-utils";
+import {
+ addItemToTier,
+ compress,
+ decompress,
+ getNextNthForItem,
+} from "../tier-list-maker-utils";
export type TierListPlacementMode = "track" | "click";
@@ -517,11 +521,10 @@ function useSearchParamTiersState() {
try {
if (param) {
- const uncrushed = JSONCrush.uncrush(param);
+ const decompressed = decompress(param);
+ if (decompressed === null) throw new Error("Failed to decompress");
- const parsed = tierListStateSerializedSchema.parse(
- JSON.parse(uncrushed),
- );
+ const parsed = tierListStateSerializedSchema.parse(decompressed);
return {
tiers: parsed.tiers,
@@ -539,12 +542,14 @@ function useSearchParamTiersState() {
const persistTiersStateToParams = (state: TierListState) => {
const searchParams = new URLSearchParams(window.location.search);
- const serializedState = JSON.stringify({
- tiers: state.tiers,
- tierItems: Array.from(state.tierItems.entries()),
- });
+ searchParams.set(
+ TIER_SEARCH_PARAM_NAME,
+ compress({
+ tiers: state.tiers,
+ tierItems: Array.from(state.tierItems.entries()),
+ }),
+ );
- searchParams.set(TIER_SEARCH_PARAM_NAME, JSONCrush.crush(serializedState));
window.history.replaceState(
{},
"",
diff --git a/app/features/tier-list-maker/tier-list-maker-utils.ts b/app/features/tier-list-maker/tier-list-maker-utils.ts
index e56babbea..c754da7b5 100644
--- a/app/features/tier-list-maker/tier-list-maker-utils.ts
+++ b/app/features/tier-list-maker/tier-list-maker-utils.ts
@@ -1,3 +1,4 @@
+import { compressToBase64, decompressFromBase64 } from "~/utils/compression";
import type { TierListItem, TierListState } from "./tier-list-maker-schemas";
export function tierListItemId(item: TierListItem) {
@@ -50,3 +51,18 @@ export function getNextNthForItem(
}, 0) + 1
);
}
+
+export function compress(obj: T) {
+ return compressToBase64(JSON.stringify(obj), { urlSafe: true });
+}
+
+export function decompress(compressed: string) {
+ const json = decompressFromBase64(compressed);
+ if (json === null) return null;
+
+ try {
+ return JSON.parse(json) as T;
+ } catch {
+ return null;
+ }
+}
diff --git a/app/features/top-search/actions/xsearch.player.$id.server.ts b/app/features/top-search/actions/xsearch.player.$id.server.ts
index 5845ef91e..38ccd91ca 100644
--- a/app/features/top-search/actions/xsearch.player.$id.server.ts
+++ b/app/features/top-search/actions/xsearch.player.$id.server.ts
@@ -1,6 +1,7 @@
import type { ActionFunctionArgs } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import * as BadgeRepository from "~/features/badges/BadgeRepository.server";
+import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
import { logger } from "~/utils/logger";
import {
errorToastIfFalsy,
@@ -36,6 +37,7 @@ export const action = async ({ params }: ActionFunctionArgs) => {
await XRankPlacementRepository.unlinkPlayerByUserId(user.id);
await BadgeRepository.syncXPBadges();
+ await TrophyRepository.syncSpecialTrophies();
await XRankPlacementRepository.refreshTenStarWeapons(user.id);
return successToast("Unlink successful");
diff --git a/app/features/tournament-bracket/actions/to.$id.brackets.finalize.server.ts b/app/features/tournament-bracket/actions/to.$id.brackets.finalize.server.ts
index 8c0b09cf5..00b428d93 100644
--- a/app/features/tournament-bracket/actions/to.$id.brackets.finalize.server.ts
+++ b/app/features/tournament-bracket/actions/to.$id.brackets.finalize.server.ts
@@ -22,8 +22,12 @@ import {
import {
finalizeTournamentActionSchema,
type TournamentBadgeReceivers,
+ type TournamentTrophyReceiver,
} from "~/features/tournament-bracket/tournament-bracket-schemas.server";
-import { validateBadgeReceivers } from "~/features/tournament-bracket/tournament-bracket-utils";
+import {
+ validateBadgeReceivers,
+ validateTrophyReceiver,
+} from "~/features/tournament-bracket/tournament-bracket-utils";
import * as TournamentMatchRepository from "~/features/tournament-match/TournamentMatchRepository.server";
import { refreshTentativeTiersCache } from "~/features/tournament-organization/core/tentativeTiers.server";
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
@@ -53,8 +57,18 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
errorToastIfFalsy(tournament.canFinalize(user), "Can't finalize tournament");
+ const event = await CalendarRepository.findById(tournament.ctx.eventId, {
+ includeBadgePrizes: true,
+ includeTrophy: true,
+ });
+ invariant(event, "Event not found for tournament");
+
const badgeOwnersValid = data.badgeReceivers
- ? await requireValidBadgeReceivers(data.badgeReceivers, tournament)
+ ? requireValidBadgeReceivers({
+ badgeReceivers: data.badgeReceivers,
+ badges: event.badgePrizes ?? [],
+ tournament,
+ })
: true;
if (!badgeOwnersValid) errorToast("New badge owners invalid");
@@ -68,6 +82,17 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
const standingsResult = Standings.tournamentStandings(tournament);
const finalStandings = Standings.flattenStandings(standingsResult);
+ const trophyReceiver = event.trophy ? (data.trophyReceiver ?? null) : null;
+ if (event.trophy) {
+ const trophyReceiverValid = requireValidTrophyReceiver({
+ trophyReceiver,
+ trophy: event.trophy,
+ finalStandings,
+ tournament,
+ });
+ if (!trophyReceiverValid) errorToast("Invalid trophy receiver");
+ }
+
const calculateSeasonalStats =
tournament.ranked && typeof season === "number";
const ratingTargets = summaryRatingTargets(results);
@@ -103,6 +128,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
summary,
season,
badgeReceivers: data.badgeReceivers ?? undefined,
+ trophyReceiver: trophyReceiver ?? undefined,
});
} else {
logger.info(
@@ -133,6 +159,12 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
notifyBadgeReceivers(data.badgeReceivers);
}
+ if (trophyReceiver) {
+ logger.info(
+ `Trophy receiver for tournament id ${tournamentId}: ${JSON.stringify(trophyReceiver)}`,
+ );
+ }
+
clearTournamentDataCache(tournamentId);
// ensure RunningTournament = sidebar updates
@@ -144,17 +176,15 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
});
};
-async function requireValidBadgeReceivers(
- badgeReceivers: TournamentBadgeReceivers,
- tournament: Tournament,
-) {
- const badges = (
- await CalendarRepository.findById(tournament.ctx.eventId, {
- includeBadgePrizes: true,
- })
- )?.badgePrizes;
- invariant(badges, "validateBadgeOwners: Event with badge prizes not found");
-
+function requireValidBadgeReceivers({
+ badgeReceivers,
+ badges,
+ tournament,
+}: {
+ badgeReceivers: TournamentBadgeReceivers;
+ badges: ReadonlyArray<{ id: number }>;
+ tournament: Tournament;
+}) {
const error = validateBadgeReceivers({
badgeReceivers,
badges,
@@ -170,6 +200,57 @@ async function requireValidBadgeReceivers(
return true;
}
+function requireValidTrophyReceiver({
+ trophyReceiver,
+ trophy,
+ finalStandings,
+ tournament,
+}: {
+ trophyReceiver: TournamentTrophyReceiver | null;
+ trophy: { id: number };
+ finalStandings: Array<{
+ placement: number;
+ team: { members: Array<{ userId: number }> };
+ }>;
+ tournament: Tournament;
+}) {
+ const error = validateTrophyReceiver({ trophyReceiver, trophy });
+ if (error) {
+ logger.warn(
+ `validateTrophyReceiver: Invalid trophy receiver for tournament ${tournament.ctx.id}: ${error}`,
+ );
+ return false;
+ }
+
+ if (!trophyReceiver) return true;
+
+ const firstPlace = finalStandings.find(
+ (standing) => standing.placement === 1,
+ );
+ if (!firstPlace) {
+ logger.warn(
+ `validateTrophyReceiver: No 1st place standing for tournament ${tournament.ctx.id}`,
+ );
+ return false;
+ }
+
+ const firstPlaceUserIds = new Set(
+ firstPlace.team.members.map((m) => m.userId),
+ );
+ const invalidUserId = trophyReceiver.userIds.find(
+ (userId) => !firstPlaceUserIds.has(userId),
+ );
+
+ if (invalidUserId !== undefined) {
+ logger.warn(
+ `validateTrophyReceiver: User ${invalidUserId} not in 1st place team for tournament ${tournament.ctx.id}`,
+ );
+ return false;
+ }
+
+ return true;
+}
+
async function notifyBadgeReceivers(badgeReceivers: TournamentBadgeReceivers) {
try {
for (const receiver of badgeReceivers) {
diff --git a/app/features/tournament-bracket/loaders/to.$id.brackets.finalize.server.ts b/app/features/tournament-bracket/loaders/to.$id.brackets.finalize.server.ts
index d2b378964..a25146279 100644
--- a/app/features/tournament-bracket/loaders/to.$id.brackets.finalize.server.ts
+++ b/app/features/tournament-bracket/loaders/to.$id.brackets.finalize.server.ts
@@ -34,19 +34,21 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
);
}
- const badges = (
- await CalendarRepository.findById(tournament.ctx.eventId, {
- includeBadgePrizes: true,
- })
- )?.badgePrizes?.sort((a, b) => a.id - b.id);
+ const event = await CalendarRepository.findById(tournament.ctx.eventId, {
+ includeBadgePrizes: true,
+ includeTrophy: true,
+ });
invariant(
- badges,
+ event?.badgePrizes,
`Tournament ${tournament.ctx.id} event not found for badges`,
);
+ const badges = event.badgePrizes.sort((a, b) => a.id - b.id);
+
return {
badges,
+ trophy: event.trophy,
standings: await standingsWithSetParticipation(tournament),
};
};
diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.finalize.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.finalize.tsx
index dd92e37c6..53fbf3bd7 100644
--- a/app/features/tournament-bracket/routes/to.$id.brackets.finalize.tsx
+++ b/app/features/tournament-bracket/routes/to.$id.brackets.finalize.tsx
@@ -10,8 +10,15 @@ import { FormMessage } from "~/components/FormMessage";
import { Placement } from "~/components/Placement";
import { SubmitButton } from "~/components/SubmitButton";
import { useTournament } from "~/features/tournament/routes/to.$id";
-import type { TournamentBadgeReceivers } from "~/features/tournament-bracket/tournament-bracket-schemas.server";
-import { validateBadgeReceivers } from "~/features/tournament-bracket/tournament-bracket-utils";
+import type {
+ TournamentBadgeReceivers,
+ TournamentTrophyReceiver,
+} from "~/features/tournament-bracket/tournament-bracket-schemas.server";
+import {
+ validateBadgeReceivers,
+ validateTrophyReceiver,
+} from "~/features/tournament-bracket/tournament-bracket-utils";
+import { Trophy } from "~/features/trophies/components/Trophy";
import { ParticipationPill } from "~/features/user-page/components/ParticipationPill";
import invariant from "~/utils/invariant";
import { action } from "../actions/to.$id.brackets.finalize.server";
@@ -26,19 +33,48 @@ export default function TournamentFinalizePage() {
const data = useLoaderData();
const { t } = useTranslation(["tournament"]);
const location = useLocation();
- const [isAssignLaterSelected, setIsAssignLaterSelected] =
+ const tournament = useTournament();
+ const firstPlaceStanding = data.standings.find(
+ (standing) => standing.placement === 1,
+ );
+ const trophyDefaultUserIds =
+ data.trophy && firstPlaceStanding
+ ? tournament.minMembersPerTeam === firstPlaceStanding.members.length
+ ? firstPlaceStanding.members.map((m) => m.userId)
+ : []
+ : [];
+
+ const [isAssignBadgesLaterSelected, setIsAssignBadgesLaterSelected] =
React.useState(false);
const [badgeReceivers, setBadgeReceivers] =
React.useState([]);
+ const [trophyReceiverUserIds, setTrophyReceiverUserIds] =
+ React.useState>(trophyDefaultUserIds);
const bracketUrl = location.pathname.replace(/\/finalize$/, "");
const tournamentHasBadges = data.badges.length > 0;
+ const tournamentHasTrophy = Boolean(data.trophy);
- const badgesError = !isAssignLaterSelected
- ? validateBadgeReceivers({ badgeReceivers, badges: data.badges })
+ const trophyReceiver: TournamentTrophyReceiver | null =
+ data.trophy && firstPlaceStanding
+ ? { trophyId: data.trophy.id, userIds: trophyReceiverUserIds }
+ : null;
+
+ const badgesError =
+ !isAssignBadgesLaterSelected && !tournamentHasTrophy
+ ? validateBadgeReceivers({ badgeReceivers, badges: data.badges })
+ : null;
+
+ const trophyError = tournamentHasTrophy
+ ? validateTrophyReceiver({
+ trophyReceiver,
+ trophy: data.trophy ?? null,
+ })
: null;
+ const error = badgesError ?? trophyError;
+
return (
- {tournamentHasBadges ? (
+ {tournamentHasTrophy && data.trophy && firstPlaceStanding ? (
+ <>
+
+
+ >
+ ) : tournamentHasBadges ? (
<>
{t("tournament:actions.finalize.assignBadgesLater")}
- {!isAssignLaterSelected ? (
+ {!isAssignBadgesLaterSelected ? (
<>
;
+ error:
+ | ReturnType
+ | ReturnType;
isAssigningBadges: boolean;
}) {
const fetcher = useFetcher();
@@ -248,3 +304,52 @@ function NewBadgeReceiversSelector({
);
}
+
+function NewTrophyReceiversSelector({
+ trophy,
+ firstPlaceStanding,
+ trophyReceiverUserIds,
+ setTrophyReceiverUserIds,
+}: {
+ trophy: NonNullable
;
+ firstPlaceStanding: FinalizeTournamentLoaderData["standings"][number];
+ trophyReceiverUserIds: Array;
+ setTrophyReceiverUserIds: (userIds: Array) => void;
+}) {
+ const handleReceiverSelected = (userId: number) => (isSelected: boolean) => {
+ if (isSelected) {
+ setTrophyReceiverUserIds([...trophyReceiverUserIds, userId]);
+ } else {
+ setTrophyReceiverUserIds(
+ trophyReceiverUserIds.filter((id) => id !== userId),
+ );
+ }
+ };
+
+ return (
+
+
+
+ {firstPlaceStanding.members.map((member, i) => {
+ return (
+
+
+
+
+ {member.username}
+
+
+ {i !== firstPlaceStanding.members.length - 1 ? (
+
+ ) : null}
+
+ );
+ })}
+
+ );
+}
diff --git a/app/features/tournament-bracket/tournament-bracket-schemas.server.ts b/app/features/tournament-bracket/tournament-bracket-schemas.server.ts
index ddc70e3f4..53f586390 100644
--- a/app/features/tournament-bracket/tournament-bracket-schemas.server.ts
+++ b/app/features/tournament-bracket/tournament-bracket-schemas.server.ts
@@ -183,6 +183,14 @@ const badgeReceivers = z.array(
}),
);
+export type TournamentTrophyReceiver = z.infer;
+
+const trophyReceiver = z.object({
+ trophyId: id,
+ userIds: z.array(id).min(1).max(50),
+});
+
export const finalizeTournamentActionSchema = z.object({
badgeReceivers: z.preprocess(safeJSONParse, badgeReceivers.nullish()),
+ trophyReceiver: z.preprocess(safeJSONParse, trophyReceiver.nullish()),
});
diff --git a/app/features/tournament-bracket/tournament-bracket-utils.ts b/app/features/tournament-bracket/tournament-bracket-utils.ts
index 77b009818..16c9695fd 100644
--- a/app/features/tournament-bracket/tournament-bracket-utils.ts
+++ b/app/features/tournament-bracket/tournament-bracket-utils.ts
@@ -1,4 +1,7 @@
-import type { TournamentBadgeReceivers } from "~/features/tournament-bracket/tournament-bracket-schemas.server";
+import type {
+ TournamentBadgeReceivers,
+ TournamentTrophyReceiver,
+} from "~/features/tournament-bracket/tournament-bracket-schemas.server";
import type { TournamentLoaderData } from "../tournament/loaders/to.$id.server";
import type { Standing } from "./core/Bracket";
@@ -106,3 +109,23 @@ export function validateBadgeReceivers({
return null;
}
+
+export function validateTrophyReceiver({
+ trophyReceiver,
+ trophy,
+}: {
+ trophyReceiver: TournamentTrophyReceiver | null;
+ trophy: { id: number } | null;
+}) {
+ if (!trophy) return null;
+
+ if (!trophyReceiver || trophyReceiver.trophyId !== trophy.id) {
+ return "TROPHY_NOT_FOUND";
+ }
+
+ if (trophyReceiver.userIds.length === 0) {
+ return "TROPHY_NOT_ASSIGNED";
+ }
+
+ return null;
+}
diff --git a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts
index 327afb435..38ef6979b 100644
--- a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts
+++ b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts
@@ -213,6 +213,26 @@ export function searchByName({
.execute();
}
+export function findOneById(id: number) {
+ return db
+ .selectFrom("TournamentOrganization")
+ .leftJoin(
+ "UserSubmittedImage",
+ "UserSubmittedImage.id",
+ "TournamentOrganization.avatarImgId",
+ )
+ .select(({ eb }) => [
+ "TournamentOrganization.id",
+ "TournamentOrganization.name",
+ "TournamentOrganization.slug",
+ concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
+ "avatarUrl",
+ ),
+ ])
+ .where("TournamentOrganization.id", "=", id)
+ .executeTakeFirst();
+}
+
interface FindEventsByMonthArgs {
month: number;
year: number;
diff --git a/app/features/tournament-organization/loaders/org.$slug.server.ts b/app/features/tournament-organization/loaders/org.$slug.server.ts
index e46bd3da8..82de97f38 100644
--- a/app/features/tournament-organization/loaders/org.$slug.server.ts
+++ b/app/features/tournament-organization/loaders/org.$slug.server.ts
@@ -2,6 +2,8 @@ import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import { getUser } from "~/features/auth/core/user.server";
import { calculateTentativeTier } from "~/features/tournament/core/tiering";
+import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
+import { canAccessTrophies } from "~/features/trophies/trophies-utils";
import type { SerializeFrom } from "~/utils/remix";
import { parseSafeSearchParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
@@ -80,6 +82,9 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
series: await seriesInfo(),
month,
year,
+ trophies: canAccessTrophies(user)
+ ? await TrophyRepository.findByOrganizationId(organization.id)
+ : [],
bannedUsers:
user?.id && organization.permissions.BAN.includes(user.id)
? await TournamentOrganizationRepository.findAllBannedUsersByOrganizationId(
diff --git a/app/features/tournament-organization/routes/org.$slug.tsx b/app/features/tournament-organization/routes/org.$slug.tsx
index fef94a9fe..8e58aba0a 100644
--- a/app/features/tournament-organization/routes/org.$slug.tsx
+++ b/app/features/tournament-organization/routes/org.$slug.tsx
@@ -6,9 +6,10 @@ import {
SquarePen,
Users,
} from "lucide-react";
+import * as React from "react";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
-import { Link, useLoaderData, useSearchParams } from "react-router";
+import { Link, useFetcher, useLoaderData, useSearchParams } from "react-router";
import { Avatar } from "~/components/Avatar";
import { Divider } from "~/components/Divider";
import { LinkButton, SendouButton } from "~/components/elements/Button";
@@ -29,6 +30,16 @@ import { TierPill } from "~/components/TierPill";
import { useUser } from "~/features/auth/core/user";
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
import { BannedUsersList } from "~/features/tournament-organization/components/BannedPlayersList";
+import {
+ Trophy,
+ TrophyContextProvider,
+ TrophyGrid,
+ TrophyPlaceholder,
+} from "~/features/trophies/components/Trophy";
+import { TrophyShowcaseModal } from "~/features/trophies/components/TrophyShowcase";
+import { TrophyTournamentHistory } from "~/features/trophies/components/TrophyTournamentHistory";
+import type { TrophyTournamentsLoaderData } from "~/features/trophies/routes/trophies.$id.tournaments";
+import { useProgressiveRender } from "~/features/trophies/trophies-utils";
import { SendouForm } from "~/form/SendouForm";
import { useHasPermission, useHasRole } from "~/modules/permissions/hooks";
import { databaseTimestampNow, databaseTimestampToDate } from "~/utils/dates";
@@ -42,6 +53,7 @@ import {
tournamentOrganizationPage,
tournamentOrganizationStatsPage,
tournamentPage,
+ trophyTournamentsPage,
userPage,
} from "~/utils/urls";
import { action } from "../actions/org.$slug.server";
@@ -71,7 +83,7 @@ export const meta: MetaFunction = (args) => {
};
export const handle: SendouRouteHandle = {
- i18n: ["badges", "org"],
+ i18n: ["badges", "org", "trophies"],
breadcrumb: ({ match }) => {
const data = match.loaderData as SerializeFrom | undefined;
@@ -209,7 +221,7 @@ function LogoHeader() {
}
function InfoTabs() {
- const { t } = useTranslation(["org"]);
+ const { t } = useTranslation(["org", "trophies"]);
const data = useLoaderData();
const isAdmin = useHasRole("ADMIN");
const canBanPlayers = useHasPermission(data.organization, "BAN");
@@ -217,6 +229,8 @@ function InfoTabs() {
const hasSocials =
data.organization.socials && data.organization.socials.length > 0;
const hasBadges = data.organization.badges.length > 0;
+ const hasTrophies = data.trophies.length > 0;
+ const hasRewards = hasBadges || hasTrophies;
return (
@@ -229,11 +243,11 @@ function InfoTabs() {
{t("org:edit.form.members.title")}
}
+ id="rewards"
+ isDisabled={!hasRewards}
+ icon={
}
>
- {t("org:edit.form.badges.title")}
+ {t("org:edit.form.rewards.title")}
{canBanPlayers && data.bannedUsers ? (
-
-
+
+
{data.bannedUsers ? (
@@ -274,6 +291,37 @@ function InfoTabs() {
);
}
+function RewardsPanel({
+ badges,
+ trophies,
+}: {
+ badges: SerializeFrom["organization"]["badges"];
+ trophies: SerializeFrom["trophies"];
+}) {
+ const { t } = useTranslation(["org", "trophies"]);
+
+ return (
+
+ {trophies.length > 0 ? (
+ <>
+
+ {t("trophies:title")}
+
+
+ >
+ ) : null}
+ {badges.length > 0 ? (
+ <>
+
+ {t("org:edit.form.badges.title")}
+
+
+ >
+ ) : null}
+
+ );
+}
+
function AdminControls() {
const data = useLoaderData();
@@ -692,3 +740,74 @@ function EventLeaderboardRow({
);
}
+
+function RewardsTrophyGrid({
+ trophies,
+}: {
+ trophies: SerializeFrom["trophies"];
+}) {
+ const visibleCount = useProgressiveRender(trophies.length, "");
+ const [openTrophy, setOpenTrophy] = React.useState<
+ SerializeFrom["trophies"][number] | null
+ >(null);
+
+ return (
+
+
+ {trophies.map((trophy, i) =>
+ i < visibleCount ? (
+ setOpenTrophy(trophy)}
+ aria-label={trophy.name}
+ >
+
+
+ ) : (
+
+ ),
+ )}
+
+ {openTrophy ? (
+ setOpenTrophy(null)}
+ >
+
+
+ ) : null}
+
+ );
+}
+
+function TrophyModalTournaments({ trophyId }: { trophyId: number }) {
+ const { t } = useTranslation(["trophies"]);
+ const fetcher = useFetcher();
+
+ const loadedRef = React.useRef(false);
+ React.useEffect(() => {
+ if (loadedRef.current) return;
+ loadedRef.current = true;
+ fetcher.load(trophyTournamentsPage(trophyId));
+ }, [fetcher.load, trophyId]);
+
+ if (!fetcher.data || fetcher.data.tournaments.length === 0) return null;
+
+ return (
+
+
{t("trophies:details.tournamentHistory")}
+
+
+ );
+}
diff --git a/app/features/tournament-organization/tournament-organization.module.css b/app/features/tournament-organization/tournament-organization.module.css
index 38e195a78..845bd7e0c 100644
--- a/app/features/tournament-organization/tournament-organization.module.css
+++ b/app/features/tournament-organization/tournament-organization.module.css
@@ -200,3 +200,23 @@
fill: #1285fe;
}
}
+
+.trophyGridButton {
+ all: unset;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.trophyModalTournaments {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-2);
+ opacity: 1;
+ transition: opacity 0.2s ease;
+
+ @starting-style {
+ opacity: 0;
+ }
+}
diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts
index cb9316df3..8dc787058 100644
--- a/app/features/tournament/TournamentRepository.server.ts
+++ b/app/features/tournament/TournamentRepository.server.ts
@@ -13,7 +13,10 @@ import { actorId } from "~/features/auth/core/user.server";
import { identifierToUserIds } from "~/features/mmr/mmr-utils";
import * as Progression from "~/features/tournament-bracket/core/Progression";
import type { TournamentSummary } from "~/features/tournament-bracket/core/summarizer.server";
-import type { TournamentBadgeReceivers } from "~/features/tournament-bracket/tournament-bracket-schemas.server";
+import type {
+ TournamentBadgeReceivers,
+ TournamentTrophyReceiver,
+} from "~/features/tournament-bracket/tournament-bracket-schemas.server";
import { modesShort } from "~/modules/in-game-lists/modes";
import { nullFilledArray, nullifyingAvg } from "~/utils/arrays";
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
@@ -1078,11 +1081,13 @@ export function finalize({
summary,
season,
badgeReceivers = [],
+ trophyReceiver,
}: {
tournamentId: number;
summary: TournamentSummary;
season?: number;
badgeReceivers?: TournamentBadgeReceivers;
+ trophyReceiver?: TournamentTrophyReceiver;
}) {
const seasonValue = season ?? null;
@@ -1245,6 +1250,29 @@ export function finalize({
.execute();
}
+ if (trophyReceiver && trophyReceiver.userIds.length > 0) {
+ const tournamentRow = await trx
+ .selectFrom("Tournament")
+ .select("tier")
+ .where("id", "=", tournamentId)
+ .executeTakeFirst();
+
+ await trx
+ .insertInto("TrophyOwner")
+ .values(
+ trophyReceiver.userIds.map((userId) => ({
+ tournamentId,
+ trophyId: trophyReceiver.trophyId,
+ userId,
+ tier: tournamentRow?.tier ?? null,
+ })),
+ )
+ .onConflict((oc) =>
+ oc.columns(["tournamentId", "userId", "trophyId"]).doNothing(),
+ )
+ .execute();
+ }
+
const tournamentResults = summary.tournamentResults
.map((tournamentResult) => ({
tournamentResult,
diff --git a/app/features/trophies/TrophyRepository.server.test.ts b/app/features/trophies/TrophyRepository.server.test.ts
new file mode 100644
index 000000000..ded5ed322
--- /dev/null
+++ b/app/features/trophies/TrophyRepository.server.test.ts
@@ -0,0 +1,295 @@
+import { afterEach, beforeEach, describe, expect, test } from "vitest";
+import { db } from "~/db/sql";
+import type { TournamentTierNumber } from "~/features/tournament/core/tiering";
+import { dateToDatabaseTimestamp } from "~/utils/dates";
+import { dbInsertUsers, dbReset } from "~/utils/Test";
+import * as TrophyRepository from "./TrophyRepository.server";
+
+describe("trophy approvals", () => {
+ let pendingTrophyId: number;
+
+ beforeEach(async () => {
+ await dbInsertUsers(4);
+ await db
+ .insertInto("TournamentOrganization")
+ .values({ name: "Test Org", slug: "test-org" })
+ .execute();
+
+ const pending = await TrophyRepository.createPending({
+ name: "Test Trophy",
+ model: "model",
+ description: "",
+ organizationId: 1,
+ submitterUserId: 1,
+ });
+ pendingTrophyId = pending.id;
+ });
+
+ afterEach(() => dbReset());
+
+ test("creates the trophy exactly once when approvals exceed the required count", async () => {
+ expect(
+ await TrophyRepository.addApproval({ pendingTrophyId, userId: 2 }),
+ ).toBe(null);
+
+ const accepted = await TrophyRepository.addApproval({
+ pendingTrophyId,
+ userId: 3,
+ });
+ expect(accepted?.id).toBeTypeOf("number");
+
+ expect(
+ await TrophyRepository.addApproval({ pendingTrophyId, userId: 4 }),
+ ).toBe(null);
+
+ expect(await trophyCount()).toBe(1);
+ });
+
+ test("ignores repeated approvals from the same user", async () => {
+ await TrophyRepository.addApproval({ pendingTrophyId, userId: 2 });
+
+ expect(
+ await TrophyRepository.addApproval({ pendingTrophyId, userId: 2 }),
+ ).toBe(null);
+
+ const pending = await TrophyRepository.findPendingById(pendingTrophyId);
+ expect(pending?.approvals.length).toBe(1);
+ expect(await trophyCount()).toBe(0);
+ });
+
+ test("re-approval after acceptance does not create another trophy", async () => {
+ await TrophyRepository.addApproval({ pendingTrophyId, userId: 2 });
+ await TrophyRepository.addApproval({ pendingTrophyId, userId: 3 });
+
+ expect(
+ await TrophyRepository.addApproval({ pendingTrophyId, userId: 2 }),
+ ).toBe(null);
+
+ expect(await trophyCount()).toBe(1);
+ });
+
+ test("declines a pending trophy that is not accepted", async () => {
+ await TrophyRepository.addApproval({ pendingTrophyId, userId: 2 });
+
+ expect(
+ await TrophyRepository.declinePending({
+ id: pendingTrophyId,
+ reason: "reason",
+ declinedByUserId: 3,
+ }),
+ ).toBe(true);
+
+ const pending = await TrophyRepository.findPendingById(pendingTrophyId);
+ expect(pending?.declinedAt).not.toBe(null);
+ expect(pending?.approvals.length).toBe(0);
+ });
+
+ test("does not decline an already accepted pending trophy", async () => {
+ await TrophyRepository.addApproval({ pendingTrophyId, userId: 2 });
+ await TrophyRepository.addApproval({ pendingTrophyId, userId: 3 });
+
+ expect(
+ await TrophyRepository.declinePending({
+ id: pendingTrophyId,
+ reason: "reason",
+ declinedByUserId: 4,
+ }),
+ ).toBe(false);
+
+ const pending = await TrophyRepository.findPendingById(pendingTrophyId);
+ expect(pending?.declinedAt).toBe(null);
+ expect(await trophyCount()).toBe(1);
+ });
+
+ test("approvals after a decline do not create a trophy", async () => {
+ await TrophyRepository.declinePending({
+ id: pendingTrophyId,
+ reason: "reason",
+ declinedByUserId: 2,
+ });
+
+ await TrophyRepository.addApproval({ pendingTrophyId, userId: 2 });
+ expect(
+ await TrophyRepository.addApproval({ pendingTrophyId, userId: 3 }),
+ ).toBe(null);
+
+ expect(await trophyCount()).toBe(0);
+ });
+});
+
+describe("trophy list tiers", () => {
+ let trophyId: number;
+
+ beforeEach(async () => {
+ await dbInsertUsers(2);
+
+ const trophy = await db
+ .insertInto("Trophy")
+ .values({
+ name: "Tiered Trophy",
+ model: "model",
+ creatorId: 1,
+ managerId: 1,
+ })
+ .returning("id")
+ .executeTakeFirstOrThrow();
+ trophyId = trophy.id;
+ });
+
+ afterEach(() => dbReset());
+
+ test("an upcoming tournament without tier info does not hide the earned tier", async () => {
+ await insertTrophyTournament({ trophyId, tier: 3, startInDays: -21 });
+ await insertTrophyTournament({ trophyId, tier: null, startInDays: 10 });
+
+ const trophy = (await TrophyRepository.all()).find(
+ (row) => row.name === "Tiered Trophy",
+ );
+
+ expect(trophy?.tier).toBe(3);
+ });
+
+ test("uses the most recent tier when multiple tournaments have one", async () => {
+ await insertTrophyTournament({ trophyId, tier: 5, startInDays: -30 });
+ await insertTrophyTournament({ trophyId, tier: 3, startInDays: -7 });
+
+ const trophy = (await TrophyRepository.all()).find(
+ (row) => row.name === "Tiered Trophy",
+ );
+
+ expect(trophy?.tier).toBe(3);
+ });
+
+ test("has no tier when no linked tournament has tier info", async () => {
+ await insertTrophyTournament({ trophyId, tier: null, startInDays: 10 });
+
+ const trophy = (await TrophyRepository.all()).find(
+ (row) => row.name === "Tiered Trophy",
+ );
+
+ expect(trophy?.tier).toBe(null);
+ expect(trophy?.tentativeTier).toBe(null);
+ });
+
+ test("returns the start time of the next upcoming tournament", async () => {
+ await insertTrophyTournament({ trophyId, tier: 3, startInDays: -21 });
+ await insertTrophyTournament({ trophyId, tier: null, startInDays: 20 });
+ await insertTrophyTournament({ trophyId, tier: null, startInDays: 10 });
+
+ const trophy = (await TrophyRepository.all()).find(
+ (row) => row.name === "Tiered Trophy",
+ );
+
+ const expected = dateToDatabaseTimestamp(
+ new Date(Date.now() + 10 * 24 * 60 * 60 * 1000),
+ );
+ expect(
+ Math.abs((trophy?.upcomingTournamentAt ?? 0) - expected),
+ ).toBeLessThan(10);
+ });
+
+ test("sorts trophies with an upcoming tournament first within the same tier", async () => {
+ await insertTrophyTournament({ trophyId, tier: 3, startInDays: -21 });
+
+ const upcoming = await db
+ .insertInto("Trophy")
+ .values({
+ name: "Upcoming Trophy",
+ model: "model",
+ creatorId: 1,
+ managerId: 1,
+ })
+ .returning("id")
+ .executeTakeFirstOrThrow();
+ await insertTrophyTournament({
+ trophyId: upcoming.id,
+ tier: 3,
+ startInDays: -14,
+ });
+ await insertTrophyTournament({
+ trophyId: upcoming.id,
+ tier: null,
+ startInDays: 10,
+ });
+
+ const distant = await db
+ .insertInto("Trophy")
+ .values({
+ name: "Distant Trophy",
+ model: "model",
+ creatorId: 1,
+ managerId: 1,
+ })
+ .returning("id")
+ .executeTakeFirstOrThrow();
+ await insertTrophyTournament({
+ trophyId: distant.id,
+ tier: 3,
+ startInDays: -7,
+ });
+ await insertTrophyTournament({
+ trophyId: distant.id,
+ tier: null,
+ startInDays: 5 * 7,
+ });
+
+ const names = (await TrophyRepository.all()).map((row) => row.name);
+
+ expect(names).toEqual([
+ "Upcoming Trophy",
+ "Tiered Trophy",
+ "Distant Trophy",
+ ]);
+ });
+});
+
+async function trophyCount() {
+ const { count } = await db
+ .selectFrom("Trophy")
+ .select((eb) => eb.fn.countAll().as("count"))
+ .executeTakeFirstOrThrow();
+
+ return count;
+}
+
+async function insertTrophyTournament({
+ trophyId,
+ tier,
+ startInDays,
+}: {
+ trophyId: number;
+ tier: TournamentTierNumber | null;
+ startInDays: number;
+}) {
+ const tournament = await db
+ .insertInto("Tournament")
+ .values({
+ mapPickingStyle: "AUTO_ALL",
+ settings: JSON.stringify({ bracketProgression: [] }),
+ tier,
+ })
+ .returning("id")
+ .executeTakeFirstOrThrow();
+
+ const event = await db
+ .insertInto("CalendarEvent")
+ .values({
+ name: `Tournament ${tournament.id}`,
+ bracketUrl: "https://example.com",
+ authorId: 1,
+ tournamentId: tournament.id,
+ trophyId,
+ })
+ .returning("id")
+ .executeTakeFirstOrThrow();
+
+ await db
+ .insertInto("CalendarEventDate")
+ .values({
+ eventId: event.id,
+ startsAt: dateToDatabaseTimestamp(
+ new Date(Date.now() + startInDays * 24 * 60 * 60 * 1000),
+ ),
+ })
+ .execute();
+}
diff --git a/app/features/trophies/TrophyRepository.server.ts b/app/features/trophies/TrophyRepository.server.ts
new file mode 100644
index 000000000..e99bc2aa9
--- /dev/null
+++ b/app/features/trophies/TrophyRepository.server.ts
@@ -0,0 +1,883 @@
+import { sub } from "date-fns";
+import type { ExpressionBuilder, NotNull, Transaction } from "kysely";
+import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
+import * as R from "remeda";
+import { db } from "~/db/sql";
+import type { DB } from "~/db/tables";
+import { isSupporter } from "~/modules/permissions/utils";
+import {
+ databaseTimestampToDate,
+ dateToDatabaseTimestamp,
+} from "~/utils/dates";
+import {
+ calendarEventStartTime,
+ commonUserSelect,
+ peakXpOverallSql,
+ tournamentLogoWithDefault,
+ tournamentTeamCount,
+} from "~/utils/kysely.server";
+import { getTentativeTier } from "../tournament-organization/core/tentativeTiers.server";
+import { sortTrophiesByFavorites } from "../user-page/core/trophy-sorting.server";
+import {
+ SUPPORTER_TROPHY_CODE,
+ TROPHY_APPROVALS_REQUIRED,
+ XP_TROPHY_CODE_PREFIX,
+} from "./trophies-constants";
+import {
+ hasUpcomingTournamentSoon,
+ parseSpecialTrophyCode,
+} from "./trophies-utils";
+
+type TrophyRecentTournament = {
+ tier: number | null;
+ name: string;
+ organizationId: number | null;
+ startTime: number | null;
+};
+
+export async function all() {
+ const rows = await db
+ .selectFrom("Trophy")
+ .select((eb) => ["id", "name", "model", withRecentTournaments(eb)])
+ .execute();
+
+ return sortByEffectiveTier(rows.map(addEffectiveTier));
+}
+
+const withRecentTournaments = (eb: ExpressionBuilder) =>
+ jsonArrayFrom(
+ eb
+ .selectFrom("CalendarEvent")
+ .innerJoin("Tournament", "Tournament.id", "CalendarEvent.tournamentId")
+ .select((eb2) => [
+ "Tournament.tier",
+ "CalendarEvent.name",
+ "CalendarEvent.organizationId",
+ calendarEventStartTime(eb2).as("startTime"),
+ ])
+ .whereRef("CalendarEvent.trophyId", "=", "Trophy.id")
+ .where("CalendarEvent.hidden", "=", 0)
+ .orderBy((eb2) => calendarEventStartTime(eb2), "desc"),
+ ).as("recentTournaments");
+
+function addEffectiveTier<
+ T extends { recentTournaments: Array },
+>({ recentTournaments, ...rest }: T) {
+ const upcomingTournamentAt = nextUpcomingStartTime(recentTournaments);
+
+ for (const tournament of recentTournaments) {
+ const tierInfo = tournamentTierInfo(tournament);
+ if (tierInfo.tier !== null || tierInfo.tentativeTier !== null) {
+ return { ...rest, ...tierInfo, upcomingTournamentAt };
+ }
+ }
+
+ return { ...rest, tier: null, tentativeTier: null, upcomingTournamentAt };
+}
+
+function nextUpcomingStartTime(tournaments: Array) {
+ const now = dateToDatabaseTimestamp(new Date());
+
+ // ordered newest first, so the last future start time is the next one up
+ const futureStartTimes = tournaments
+ .map((tournament) => tournament.startTime)
+ .filter(
+ (startTime): startTime is number => startTime !== null && startTime > now,
+ );
+
+ return futureStartTimes.at(-1) ?? null;
+}
+
+function tournamentTierInfo(tournament: TrophyRecentTournament) {
+ const isPastEvent =
+ tournament.startTime !== null &&
+ databaseTimestampToDate(tournament.startTime) <
+ sub(new Date(), { days: 1 });
+
+ const tentativeTier =
+ tournament.tier === null &&
+ tournament.organizationId !== null &&
+ !isPastEvent
+ ? getTentativeTier(tournament.organizationId, tournament.name)
+ : null;
+
+ return { tier: tournament.tier, tentativeTier };
+}
+
+function sortByEffectiveTier<
+ T extends {
+ id: number;
+ tier: number | null;
+ tentativeTier: number | null;
+ upcomingTournamentAt: number | null;
+ },
+>(rows: T[]) {
+ return R.sortBy(
+ rows,
+ (row) => row.tier ?? row.tentativeTier ?? Number.MAX_SAFE_INTEGER,
+ (row) => (hasUpcomingTournamentSoon(row.upcomingTournamentAt) ? 0 : 1),
+ (row) => row.id,
+ );
+}
+
+const withCreator = (eb: ExpressionBuilder) => {
+ return jsonObjectFrom(
+ eb
+ .selectFrom("User")
+ .select((eb) => commonUserSelect(eb))
+ .whereRef("User.id", "=", "Trophy.creatorId"),
+ ).as("creator");
+};
+
+const withManager = (eb: ExpressionBuilder) => {
+ return jsonObjectFrom(
+ eb
+ .selectFrom("User")
+ .select((eb) => commonUserSelect(eb))
+ .whereRef("User.id", "=", "Trophy.managerId"),
+ ).as("manager");
+};
+
+const withOrganization = (eb: ExpressionBuilder) => {
+ return jsonObjectFrom(
+ eb
+ .selectFrom("TournamentOrganization")
+ .select(["TournamentOrganization.name", "TournamentOrganization.slug"])
+ .whereRef("TournamentOrganization.id", "=", "Trophy.organizationId"),
+ ).as("organization");
+};
+
+const withOwners = (eb: ExpressionBuilder) => {
+ return jsonArrayFrom(
+ eb
+ .selectFrom("TrophyOwner")
+ .innerJoin("User", "TrophyOwner.userId", "User.id")
+ .select((eb) => [
+ eb.fn.count("TrophyOwner.trophyId").as("count"),
+ ...commonUserSelect(eb),
+ ])
+ .whereRef("TrophyOwner.trophyId", "=", "Trophy.id")
+ .groupBy("User.id")
+ .orderBy("count", "desc"),
+ ).as("owners");
+};
+
+const withSpecialOwners = (eb: ExpressionBuilder) => {
+ return jsonArrayFrom(
+ eb
+ .selectFrom("SpecialTrophyOwner")
+ .innerJoin("User", "SpecialTrophyOwner.userId", "User.id")
+ .select((eb) => [eb.val(1).as("count"), ...commonUserSelect(eb)])
+ .whereRef("SpecialTrophyOwner.trophyId", "=", "Trophy.id")
+ .orderBy("User.id", "asc"),
+ ).as("specialOwners");
+};
+
+export async function findByOrganizationId(organizationId: number) {
+ const rows = await db
+ .selectFrom("Trophy")
+ .select((eb) => ["id", "name", "model", withRecentTournaments(eb)])
+ .where("organizationId", "=", organizationId)
+ .execute();
+
+ return sortByEffectiveTier(rows.map(addEffectiveTier));
+}
+
+export async function findByOrganizationIds(organizationIds: number[]) {
+ if (organizationIds.length === 0) return [];
+
+ return db
+ .selectFrom("Trophy")
+ .select(["id", "name", "model", "organizationId"])
+ .where("organizationId", "in", organizationIds)
+ .execute();
+}
+
+async function findOwnedTrophies(userId: number) {
+ const tournamentRows = await db
+ .selectFrom("TrophyOwner")
+ .innerJoin("Trophy", "Trophy.id", "TrophyOwner.trophyId")
+ .innerJoin("User", "User.id", "TrophyOwner.userId")
+ .select(({ fn }) => [
+ fn.count("TrophyOwner.trophyId").as("count"),
+ fn.min("TrophyOwner.tier").as("tier"),
+ "Trophy.id",
+ "Trophy.name",
+ "Trophy.model",
+ "Trophy.code",
+ "User.favoriteTrophyIds",
+ "User.hiddenTrophyIds",
+ "User.patronTier",
+ ])
+ .where("TrophyOwner.userId", "=", userId)
+ .groupBy(["TrophyOwner.trophyId", "TrophyOwner.userId"])
+ .execute();
+
+ const specialRows = await db
+ .selectFrom("SpecialTrophyOwner")
+ .innerJoin("Trophy", "Trophy.id", "SpecialTrophyOwner.trophyId")
+ .innerJoin("User", "User.id", "SpecialTrophyOwner.userId")
+ .select([
+ "Trophy.id",
+ "Trophy.name",
+ "Trophy.model",
+ "Trophy.code",
+ "User.favoriteTrophyIds",
+ "User.hiddenTrophyIds",
+ "User.patronTier",
+ ])
+ .where("SpecialTrophyOwner.userId", "=", userId)
+ .execute();
+
+ return [
+ ...tournamentRows,
+ ...specialRows.map((row) => ({ ...row, count: 1, tier: null })),
+ ];
+}
+
+export async function findByOwnerUserId(userId: number) {
+ const rows = await findOwnedTrophies(userId);
+
+ if (rows.length === 0) return [];
+
+ const { favoriteTrophyIds, hiddenTrophyIds, patronTier } = rows[0];
+ const hiddenSet = new Set(hiddenTrophyIds ?? []);
+
+ return sortTrophiesByFavorites({
+ favoriteTrophyIds,
+ hiddenTrophyIds,
+ patronTier,
+ trophies: rows
+ .filter((row) => !hiddenSet.has(row.id))
+ .map((row) =>
+ R.omit(row, ["favoriteTrophyIds", "hiddenTrophyIds", "patronTier"]),
+ ),
+ }).trophies;
+}
+
+export async function findByOwnerUserIdIncludingHidden(userId: number) {
+ const rows = await findOwnedTrophies(userId);
+
+ return rows.map((row) =>
+ R.omit(row, ["favoriteTrophyIds", "hiddenTrophyIds", "patronTier"]),
+ );
+}
+
+export async function findById(trophyId: number) {
+ const row = await db
+ .selectFrom("Trophy")
+ .select((eb) => [
+ "Trophy.id",
+ "Trophy.name",
+ "Trophy.model",
+ "Trophy.code",
+ withCreator(eb),
+ withManager(eb),
+ withOrganization(eb),
+ withOwners(eb),
+ withSpecialOwners(eb),
+ ])
+ .where("Trophy.id", "=", trophyId)
+ .executeTakeFirst();
+
+ if (!row) return null;
+
+ const { specialOwners, ...trophy } = row;
+
+ return { ...trophy, owners: [...trophy.owners, ...specialOwners] };
+}
+
+export async function findTournamentsByTrophyId(trophyId: number) {
+ const rows = await db
+ .selectFrom("CalendarEvent")
+ .innerJoin("Tournament", "Tournament.id", "CalendarEvent.tournamentId")
+ .select((eb) => [
+ "Tournament.id as tournamentId",
+ "CalendarEvent.name",
+ "CalendarEvent.organizationId",
+ "Tournament.tier",
+ tournamentLogoWithDefault(eb).as("logoUrl"),
+ calendarEventStartTime(eb).as("startTime"),
+ tournamentTeamCount(eb).as("teamsCount"),
+ ])
+ .where("CalendarEvent.trophyId", "=", trophyId)
+ .where("CalendarEvent.hidden", "=", 0)
+ .orderBy("startTime", "desc")
+ .execute();
+
+ return rows.map((row) => ({
+ tournamentId: row.tournamentId,
+ name: row.name,
+ logoUrl: row.logoUrl,
+ startTime: row.startTime,
+ teamsCount: row.teamsCount,
+ ...tournamentTierInfo(row),
+ }));
+}
+
+export async function findWinsByOwner({
+ trophyId,
+ userId,
+}: {
+ trophyId: number;
+ userId: number;
+}) {
+ const tournaments = await db
+ .selectFrom("TrophyOwner")
+ .innerJoin("Tournament", "Tournament.id", "TrophyOwner.tournamentId")
+ .innerJoin("CalendarEvent", "CalendarEvent.tournamentId", "Tournament.id")
+ .select((eb) => [
+ "TrophyOwner.tournamentId",
+ "TrophyOwner.tier",
+ "CalendarEvent.name",
+ tournamentLogoWithDefault(eb).as("logoUrl"),
+ calendarEventStartTime(eb).as("startTime"),
+ tournamentTeamCount(eb).as("teamsCount"),
+ ])
+ .where("TrophyOwner.trophyId", "=", trophyId)
+ .where("TrophyOwner.userId", "=", userId)
+ .where("CalendarEvent.hidden", "=", 0)
+ .orderBy("startTime", "desc")
+ .execute();
+
+ if (tournaments.length === 0) return [];
+
+ const tournamentIds = tournaments.map(
+ (tournament) => tournament.tournamentId,
+ );
+
+ const ownResults = await db
+ .selectFrom("TournamentResult")
+ .select(["tournamentId", "tournamentTeamId"])
+ .where("userId", "=", userId)
+ .where("tournamentId", "in", tournamentIds)
+ .execute();
+
+ const teamIds = ownResults.map((result) => result.tournamentTeamId);
+
+ const memberRows =
+ teamIds.length > 0
+ ? await db
+ .selectFrom("TournamentResult")
+ .innerJoin("User", "User.id", "TournamentResult.userId")
+ .select((eb) => [
+ "TournamentResult.tournamentTeamId",
+ "TournamentResult.setResults",
+ ...commonUserSelect(eb),
+ ])
+ .where("TournamentResult.tournamentTeamId", "in", teamIds)
+ .execute()
+ : [];
+
+ const memberIds = R.unique(memberRows.map((member) => member.id));
+
+ const weaponRows =
+ memberIds.length > 0
+ ? await db
+ .selectFrom("ReportedWeapon")
+ .innerJoin(
+ "TournamentMatch",
+ "TournamentMatch.id",
+ "ReportedWeapon.tournamentMatchId",
+ )
+ .innerJoin(
+ "TournamentStage",
+ "TournamentStage.id",
+ "TournamentMatch.stageId",
+ )
+ .select(({ fn }) => [
+ "TournamentStage.tournamentId",
+ "ReportedWeapon.userId",
+ "ReportedWeapon.weaponSplId",
+ fn.countAll().as("count"),
+ ])
+ .where("TournamentStage.tournamentId", "in", tournamentIds)
+ .where("ReportedWeapon.userId", "in", memberIds)
+ .groupBy([
+ "TournamentStage.tournamentId",
+ "ReportedWeapon.userId",
+ "ReportedWeapon.weaponSplId",
+ ])
+ .orderBy("count", "desc")
+ .execute()
+ : [];
+
+ return tournaments.map((tournament) => {
+ const ownResult = ownResults.find(
+ (result) => result.tournamentId === tournament.tournamentId,
+ );
+
+ const members = memberRows
+ .filter(
+ (member) => member.tournamentTeamId === ownResult?.tournamentTeamId,
+ )
+ .map((member) => ({
+ ...R.omit(member, ["tournamentTeamId"]),
+ weapons: weaponRows
+ .filter(
+ (weapon) =>
+ weapon.tournamentId === tournament.tournamentId &&
+ weapon.userId === member.id,
+ )
+ .map((weapon) => weapon.weaponSplId),
+ }));
+
+ return {
+ ...tournament,
+ members: R.sortBy(
+ members,
+ (member) => (member.id === userId ? 0 : 1),
+ (member) => member.username.toLowerCase(),
+ ),
+ };
+ });
+}
+
+export async function findOrganizationIdById(trophyId: number) {
+ const row = await db
+ .selectFrom("Trophy")
+ .select("organizationId")
+ .where("id", "=", trophyId)
+ .executeTakeFirst();
+
+ return row?.organizationId ?? null;
+}
+
+export async function existsByName(args: {
+ name: string;
+ excludeTrophyId?: number;
+}) {
+ let trophyQuery = db
+ .selectFrom("Trophy")
+ .select("id")
+ .where("name", "=", args.name);
+
+ if (args.excludeTrophyId !== undefined) {
+ trophyQuery = trophyQuery.where("id", "!=", args.excludeTrophyId);
+ }
+
+ const trophy = await trophyQuery.executeTakeFirst();
+
+ if (trophy) return true;
+
+ let pendingQuery = db
+ .selectFrom("PendingTrophy")
+ .select("id")
+ .where("name", "=", args.name)
+ .where("declinedAt", "is", null);
+
+ if (args.excludeTrophyId !== undefined) {
+ pendingQuery = pendingQuery.where(
+ "targetTrophyId",
+ "is not",
+ args.excludeTrophyId,
+ );
+ }
+
+ const pending = await pendingQuery.executeTakeFirst();
+
+ return Boolean(pending);
+}
+
+export async function findManagedBy(userId: number) {
+ return db
+ .selectFrom("Trophy")
+ .select(["id", "name", "model", "organizationId", "managerId"])
+ .where("managerId", "=", userId)
+ .execute();
+}
+
+export async function findAllForEditing() {
+ return db
+ .selectFrom("Trophy")
+ .select(["id", "name", "model", "organizationId", "managerId"])
+ .where("code", "is", null)
+ .execute();
+}
+
+/**
+ * Recomputes ownership of every special trophy (supporter, XP).
+ * Existing owner rows that are still eligible keep their original `createdAt`.
+ */
+export function syncSpecialTrophies() {
+ return db.transaction().execute(async (trx) => {
+ await syncSupporterTrophyOwners(trx);
+ await syncXpTrophyOwners(trx);
+ });
+}
+
+async function syncSupporterTrophyOwners(trx: Transaction) {
+ const trophy = await trx
+ .selectFrom("Trophy")
+ .select("id")
+ .where("code", "=", SUPPORTER_TROPHY_CODE)
+ .executeTakeFirst();
+
+ if (!trophy) return;
+
+ const patrons = await trx
+ .selectFrom("User")
+ .select(["id", "patronTier"])
+ .where("patronTier", "is not", null)
+ .execute();
+
+ await replaceSpecialTrophyOwners({
+ trx,
+ trophyId: trophy.id,
+ userIds: patrons.filter(isSupporter).map((patron) => patron.id),
+ });
+}
+
+async function syncXpTrophyOwners(trx: Transaction) {
+ const xpTrophies = (
+ await trx
+ .selectFrom("Trophy")
+ .select(["id", "code"])
+ .where("code", "like", `${XP_TROPHY_CODE_PREFIX}%`)
+ .execute()
+ ).flatMap((trophy) => {
+ const parsed = parseSpecialTrophyCode(trophy.code);
+ return parsed?.type === "xp"
+ ? [{ id: trophy.id, value: parsed.value }]
+ : [];
+ });
+
+ if (xpTrophies.length === 0) return;
+
+ const byValueDesc = R.sortBy(xpTrophies, [(trophy) => trophy.value, "desc"]);
+
+ const userPeakXps = await trx
+ .selectFrom("SplatoonPlayer")
+ .select(["userId", peakXpOverallSql().as("peakXp")])
+ .where("userId", "is not", null)
+ .where("peakXp", "is not", null)
+ .$narrowType<{ userId: NotNull; peakXp: NotNull }>()
+ .execute();
+
+ const ownersByTrophyId = new Map(
+ xpTrophies.map((trophy) => [trophy.id, []]),
+ );
+ for (const { userId, peakXp } of userPeakXps) {
+ const highestReached = byValueDesc.find((trophy) => peakXp >= trophy.value);
+ if (!highestReached) continue;
+
+ ownersByTrophyId.get(highestReached.id)?.push(userId);
+ }
+
+ for (const [trophyId, userIds] of ownersByTrophyId) {
+ await replaceSpecialTrophyOwners({ trx, trophyId, userIds });
+ }
+}
+
+async function replaceSpecialTrophyOwners({
+ trx,
+ trophyId,
+ userIds,
+}: {
+ trx: Transaction;
+ trophyId: number;
+ userIds: number[];
+}) {
+ let deleteStale = trx
+ .deleteFrom("SpecialTrophyOwner")
+ .where("trophyId", "=", trophyId);
+ if (userIds.length > 0) {
+ deleteStale = deleteStale.where("userId", "not in", userIds);
+ }
+ await deleteStale.execute();
+
+ if (userIds.length === 0) return;
+
+ await trx
+ .insertInto("SpecialTrophyOwner")
+ .values(
+ userIds.map((userId) => ({
+ trophyId,
+ userId,
+ createdAt: dateToDatabaseTimestamp(new Date()),
+ })),
+ )
+ .onConflict((oc) => oc.doNothing())
+ .execute();
+}
+
+export async function createPending(args: {
+ name: string;
+ model: string;
+ description: string;
+ organizationId: number;
+ submitterUserId: number;
+ targetTrophyId?: number;
+ managerId?: number;
+}) {
+ return db
+ .insertInto("PendingTrophy")
+ .values({
+ name: args.name,
+ model: args.model,
+ description: args.description,
+ organizationId: args.organizationId,
+ submitterUserId: args.submitterUserId,
+ createdAt: dateToDatabaseTimestamp(new Date()),
+ declineReason: null,
+ declinedAt: null,
+ declinedByUserId: null,
+ targetTrophyId: args.targetTrophyId ?? null,
+ managerId: args.managerId ?? null,
+ })
+ .returning("id")
+ .executeTakeFirstOrThrow();
+}
+
+const withApprovals = (eb: ExpressionBuilder) => {
+ return jsonArrayFrom(
+ eb
+ .selectFrom("PendingTrophyApproval")
+ .innerJoin("User", "PendingTrophyApproval.userId", "User.id")
+ .select([
+ "PendingTrophyApproval.userId",
+ "PendingTrophyApproval.createdAt",
+ "User.username",
+ ])
+ .whereRef(
+ "PendingTrophyApproval.pendingTrophyId",
+ "=",
+ "PendingTrophy.id",
+ )
+ .orderBy("PendingTrophyApproval.createdAt", "asc"),
+ ).as("approvals");
+};
+
+const withTarget = (eb: ExpressionBuilder) => {
+ return jsonObjectFrom(
+ eb
+ .selectFrom("Trophy")
+ .leftJoin("User", "User.id", "Trophy.managerId")
+ .leftJoin(
+ "TournamentOrganization",
+ "TournamentOrganization.id",
+ "Trophy.organizationId",
+ )
+ .select([
+ "Trophy.id",
+ "Trophy.name",
+ "Trophy.model",
+ "Trophy.organizationId",
+ "Trophy.managerId",
+ "User.username as managerUsername",
+ "TournamentOrganization.name as organizationName",
+ "TournamentOrganization.slug as organizationSlug",
+ ])
+ .whereRef("Trophy.id", "=", "PendingTrophy.targetTrophyId"),
+ ).as("target");
+};
+
+const withTargetManager = (eb: ExpressionBuilder) => {
+ return jsonObjectFrom(
+ eb
+ .selectFrom("User")
+ .select(["User.id", "User.username", "User.discordId"])
+ .whereRef("User.id", "=", "PendingTrophy.managerId"),
+ ).as("manager");
+};
+
+function pendingBaseQuery() {
+ return db
+ .selectFrom("PendingTrophy")
+ .leftJoin(
+ "User as Submitter",
+ "Submitter.id",
+ "PendingTrophy.submitterUserId",
+ )
+ .leftJoin(
+ "User as Decliner",
+ "Decliner.id",
+ "PendingTrophy.declinedByUserId",
+ )
+ .leftJoin(
+ "TournamentOrganization",
+ "TournamentOrganization.id",
+ "PendingTrophy.organizationId",
+ )
+ .select((eb) => [
+ "PendingTrophy.id",
+ "PendingTrophy.name",
+ "PendingTrophy.model",
+ "PendingTrophy.description",
+ "PendingTrophy.organizationId",
+ "PendingTrophy.submitterUserId",
+ "PendingTrophy.createdAt",
+ "PendingTrophy.declineReason",
+ "PendingTrophy.declinedAt",
+ "PendingTrophy.declinedByUserId",
+ "PendingTrophy.targetTrophyId",
+ "PendingTrophy.managerId",
+ "Submitter.username as submitterUsername",
+ "Submitter.discordId as submitterDiscordId",
+ "Decliner.username as declinedByUsername",
+ "TournamentOrganization.name as organizationName",
+ "TournamentOrganization.slug as organizationSlug",
+ withApprovals(eb),
+ withTarget(eb),
+ withTargetManager(eb),
+ ]);
+}
+
+export async function findPendingById(id: number) {
+ const row = await pendingBaseQuery()
+ .where("PendingTrophy.id", "=", id)
+ .executeTakeFirst();
+
+ return row ?? null;
+}
+
+export async function allPending() {
+ return pendingBaseQuery()
+ .orderBy("PendingTrophy.createdAt", "desc")
+ .execute();
+}
+
+export async function pendingBySubmitter(submitterUserId: number) {
+ return pendingBaseQuery()
+ .where("PendingTrophy.submitterUserId", "=", submitterUserId)
+ .orderBy("PendingTrophy.createdAt", "desc")
+ .execute();
+}
+
+export async function unreviewedCountBySubmitter(submitterUserId: number) {
+ const row = await db
+ .selectFrom("PendingTrophy")
+ .select((eb) => eb.fn.countAll().as("count"))
+ .where("submitterUserId", "=", submitterUserId)
+ .where("declinedAt", "is", null)
+ .where((eb) =>
+ eb(
+ eb
+ .selectFrom("PendingTrophyApproval")
+ .select((eb2) => eb2.fn.countAll().as("approvalCount"))
+ .whereRef(
+ "PendingTrophyApproval.pendingTrophyId",
+ "=",
+ "PendingTrophy.id",
+ ),
+ "<",
+ TROPHY_APPROVALS_REQUIRED,
+ ),
+ )
+ .executeTakeFirstOrThrow();
+
+ return row.count;
+}
+
+export async function deletePending(id: number) {
+ await db.deleteFrom("PendingTrophy").where("id", "=", id).execute();
+}
+
+export async function declinePending(args: {
+ id: number;
+ reason: string;
+ declinedByUserId: number;
+}) {
+ return db.transaction().execute(async (trx) => {
+ const { count } = await trx
+ .selectFrom("PendingTrophyApproval")
+ .select((eb) => eb.fn.countAll().as("count"))
+ .where("pendingTrophyId", "=", args.id)
+ .executeTakeFirstOrThrow();
+
+ if (count >= TROPHY_APPROVALS_REQUIRED) {
+ return false;
+ }
+
+ await trx
+ .deleteFrom("PendingTrophyApproval")
+ .where("pendingTrophyId", "=", args.id)
+ .execute();
+
+ await trx
+ .updateTable("PendingTrophy")
+ .set({
+ declineReason: args.reason,
+ declinedAt: dateToDatabaseTimestamp(new Date()),
+ declinedByUserId: args.declinedByUserId,
+ })
+ .where("id", "=", args.id)
+ .execute();
+
+ return true;
+ });
+}
+
+export async function addApproval(args: {
+ pendingTrophyId: number;
+ userId: number;
+}) {
+ return db.transaction().execute(async (trx) => {
+ const insertResult = await trx
+ .insertInto("PendingTrophyApproval")
+ .values({
+ pendingTrophyId: args.pendingTrophyId,
+ userId: args.userId,
+ createdAt: dateToDatabaseTimestamp(new Date()),
+ })
+ .onConflict((oc) => oc.doNothing())
+ .executeTakeFirst();
+
+ if (!insertResult.numInsertedOrUpdatedRows) {
+ return null;
+ }
+
+ const { count } = await trx
+ .selectFrom("PendingTrophyApproval")
+ .select((eb) => eb.fn.countAll().as("count"))
+ .where("pendingTrophyId", "=", args.pendingTrophyId)
+ .executeTakeFirstOrThrow();
+
+ if (count !== TROPHY_APPROVALS_REQUIRED) {
+ return null;
+ }
+
+ const pending = await trx
+ .selectFrom("PendingTrophy")
+ .select([
+ "id",
+ "name",
+ "model",
+ "organizationId",
+ "submitterUserId",
+ "targetTrophyId",
+ "managerId",
+ ])
+ .where("id", "=", args.pendingTrophyId)
+ .where("declinedAt", "is", null)
+ .executeTakeFirst();
+
+ if (!pending) return null;
+
+ if (pending.targetTrophyId !== null) {
+ await trx
+ .updateTable("Trophy")
+ .set({
+ name: pending.name,
+ model: pending.model,
+ organizationId: pending.organizationId,
+ managerId: pending.managerId ?? pending.submitterUserId,
+ })
+ .where("id", "=", pending.targetTrophyId)
+ .execute();
+ return { id: pending.targetTrophyId };
+ }
+
+ return trx
+ .insertInto("Trophy")
+ .values({
+ name: pending.name,
+ model: pending.model,
+ organizationId: pending.organizationId,
+ creatorId: pending.submitterUserId,
+ managerId: pending.managerId ?? pending.submitterUserId,
+ })
+ .returning("id")
+ .executeTakeFirstOrThrow();
+ });
+}
diff --git a/app/features/trophies/actions/trophies.new.server.ts b/app/features/trophies/actions/trophies.new.server.ts
new file mode 100644
index 000000000..521f82788
--- /dev/null
+++ b/app/features/trophies/actions/trophies.new.server.ts
@@ -0,0 +1,231 @@
+import type { ActionFunction } from "react-router";
+import { ADMIN_ID, QA_IDS } from "~/features/admin/admin-constants";
+import {
+ type AuthenticatedUser,
+ requireUser,
+} from "~/features/auth/core/user.server";
+import { notify } from "~/features/notifications/core/notify.server";
+import { clearTrophiesCache } from "~/features/trophies/loaders/trophies.server";
+import * as UserRepository from "~/features/user-page/UserRepository.server";
+import { parseFormData } from "~/form/parse.server";
+import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
+import { assertUnreachable } from "~/utils/types";
+import * as TrophyRepository from "../TrophyRepository.server";
+import {
+ TROPHY_APPROVALS_REQUIRED,
+ TROPHY_PENDING_PER_USER_LIMIT,
+} from "../trophies-constants";
+import {
+ pendingTrophyActionSchema,
+ trophyFormSchema,
+} from "../trophies-schemas";
+import {
+ canAccessTrophies,
+ canEditTrophy,
+ canReviewTrophies,
+ compressTrophyModel,
+} from "../trophies-utils";
+
+export const action: ActionFunction = async ({ request }) => {
+ const user = requireUser();
+ if (!canAccessTrophies(user)) {
+ throw new Response(null, { status: 404 });
+ }
+
+ const isJson = request.headers.get("Content-Type") === "application/json";
+
+ if (isJson) {
+ const result = await parseFormData({
+ request,
+ schema: trophyFormSchema,
+ });
+
+ if (!result.success) {
+ return { fieldErrors: result.fieldErrors };
+ }
+
+ const data = result.data;
+
+ const pendingCount = await TrophyRepository.unreviewedCountBySubmitter(
+ user.id,
+ );
+ errorToastIfFalsy(
+ pendingCount < TROPHY_PENDING_PER_USER_LIMIT,
+ "Pending trophy limit reached",
+ );
+
+ if (data._action === "UPDATE") {
+ const trophy = await TrophyRepository.findById(data.targetTrophyId);
+ errorToastIfFalsy(trophy, "Trophy not found");
+ errorToastIfFalsy(
+ canEditTrophy(user, { managerId: trophy.manager?.id ?? null }),
+ "Not allowed",
+ );
+
+ const nameExists = await TrophyRepository.existsByName({
+ name: data.name,
+ excludeTrophyId: data.targetTrophyId,
+ });
+ if (nameExists) {
+ return { fieldErrors: { name: "forms:errors.trophyNameTaken" } };
+ }
+
+ await TrophyRepository.createPending({
+ name: data.name,
+ model: compressTrophyModel(data.model),
+ description: data.description ?? "",
+ organizationId: data.organizationId,
+ submitterUserId: user.id,
+ targetTrophyId: data.targetTrophyId,
+ managerId: data.managerId,
+ });
+
+ await notifyReviewersOfSubmission({
+ trophyName: data.name,
+ submitter: user,
+ });
+
+ return null;
+ }
+
+ const nameExists = await TrophyRepository.existsByName({
+ name: data.name,
+ });
+ if (nameExists) {
+ return { fieldErrors: { name: "forms:errors.trophyNameTaken" } };
+ }
+
+ await TrophyRepository.createPending({
+ name: data.name,
+ model: compressTrophyModel(data.model),
+ description: data.description ?? "",
+ organizationId: data.organizationId,
+ submitterUserId: user.id,
+ });
+
+ await notifyReviewersOfSubmission({
+ trophyName: data.name,
+ submitter: user,
+ });
+
+ return null;
+ }
+
+ const data = await parseRequestPayload({
+ request,
+ schema: pendingTrophyActionSchema,
+ });
+
+ switch (data._action) {
+ case "DELETE": {
+ const pending = await TrophyRepository.findPendingById(
+ data.pendingTrophyId,
+ );
+ errorToastIfFalsy(pending, "Pending trophy not found");
+
+ const isOwner = pending.submitterUserId === user.id;
+ const canReview = canReviewTrophies(user);
+ errorToastIfFalsy(isOwner || canReview, "Not allowed");
+
+ await TrophyRepository.deletePending(data.pendingTrophyId);
+ return null;
+ }
+ case "DECLINE": {
+ errorToastIfFalsy(canReviewTrophies(user), "Not allowed");
+
+ const pending = await TrophyRepository.findPendingById(
+ data.pendingTrophyId,
+ );
+ errorToastIfFalsy(pending, "Pending trophy not found");
+ errorToastIfFalsy(!pending.declinedAt, "Trophy is already declined");
+ errorToastIfFalsy(
+ pending.approvals.length < TROPHY_APPROVALS_REQUIRED,
+ "Cannot decline an accepted trophy",
+ );
+
+ const declined = await TrophyRepository.declinePending({
+ id: data.pendingTrophyId,
+ reason: data.reason,
+ declinedByUserId: user.id,
+ });
+ errorToastIfFalsy(declined, "Cannot decline an accepted trophy");
+
+ if (pending.submitterUserId !== user.id) {
+ notify({
+ userIds: [pending.submitterUserId],
+ notification: {
+ type: "TROPHY_SUBMISSION_DECLINED",
+ meta: { trophyName: pending.name },
+ },
+ });
+ }
+
+ return null;
+ }
+ case "APPROVE": {
+ errorToastIfFalsy(canReviewTrophies(user), "Not allowed");
+
+ const pending = await TrophyRepository.findPendingById(
+ data.pendingTrophyId,
+ );
+
+ errorToastIfFalsy(pending, "Pending trophy not found");
+ errorToastIfFalsy(
+ !pending.declinedAt,
+ "Cannot approve a declined trophy",
+ );
+ errorToastIfFalsy(
+ pending.approvals.length < TROPHY_APPROVALS_REQUIRED,
+ "Trophy is already accepted",
+ );
+ errorToastIfFalsy(
+ !pending.approvals.some((a) => a.userId === user.id),
+ "Already approved",
+ );
+
+ const inserted = await TrophyRepository.addApproval({
+ pendingTrophyId: data.pendingTrophyId,
+ userId: user.id,
+ });
+
+ if (inserted) {
+ clearTrophiesCache();
+
+ if (pending.submitterUserId !== user.id) {
+ notify({
+ userIds: [pending.submitterUserId],
+ notification: {
+ type: "TROPHY_SUBMISSION_ACCEPTED",
+ meta: { trophyName: pending.name, trophyId: inserted.id },
+ },
+ });
+ }
+ }
+
+ return null;
+ }
+ default: {
+ assertUnreachable(data);
+ }
+ }
+};
+
+async function notifyReviewersOfSubmission({
+ trophyName,
+ submitter,
+}: {
+ trophyName: string;
+ submitter: AuthenticatedUser;
+}) {
+ const reviewerIds = await UserRepository.existingUserIds(
+ [ADMIN_ID, ...QA_IDS].filter((id) => id !== submitter.id),
+ );
+
+ notify({
+ userIds: reviewerIds,
+ notification: {
+ type: "TROPHY_SUBMITTED",
+ meta: { trophyName, submitterUsername: submitter.username },
+ },
+ });
+}
diff --git a/app/features/trophies/components/TournamentSummaryRow.module.css b/app/features/trophies/components/TournamentSummaryRow.module.css
new file mode 100644
index 000000000..3f5708a44
--- /dev/null
+++ b/app/features/trophies/components/TournamentSummaryRow.module.css
@@ -0,0 +1,57 @@
+.row {
+ display: flex;
+ align-items: center;
+ gap: var(--s-2);
+ padding: var(--s-1-5);
+ border-radius: var(--radius-field);
+
+ &:hover {
+ background-color: var(--color-bg-high);
+ }
+}
+
+.logo {
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.name {
+ font-size: var(--font-sm);
+ font-weight: var(--weight-semi);
+ display: flex;
+ align-items: center;
+ gap: 0 var(--s-2);
+ flex-wrap: wrap;
+}
+
+.upcomingPill {
+ display: inline-flex;
+ align-items: center;
+ height: var(--selector-size-xs);
+ padding: 0 var(--s-1-5);
+ border-radius: var(--radius-selector);
+ background-color: var(--color-text-accent);
+ color: var(--color-text-inverse);
+ font-size: var(--font-2xs);
+ font-weight: var(--weight-bold);
+}
+
+.meta {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--s-2);
+ color: var(--color-text-high);
+ font-size: var(--font-2xs);
+}
+
+.metaItem {
+ display: flex;
+ align-items: center;
+ gap: var(--s-1);
+}
+
+.metaIcon {
+ width: 12px;
+ height: 12px;
+}
diff --git a/app/features/trophies/components/TournamentSummaryRow.tsx b/app/features/trophies/components/TournamentSummaryRow.tsx
new file mode 100644
index 000000000..250274d71
--- /dev/null
+++ b/app/features/trophies/components/TournamentSummaryRow.tsx
@@ -0,0 +1,99 @@
+import { clsx } from "clsx";
+import { Users } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import { Link } from "react-router";
+import { TierPill } from "~/components/TierPill";
+import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
+import { useFormatDistanceToNow } from "~/hooks/intl/useFormatDistanceToNow";
+import { useHydrated } from "~/hooks/useHydrated";
+import { databaseTimestampToDate } from "~/utils/dates";
+import { tournamentPage } from "~/utils/urls";
+import styles from "./TournamentSummaryRow.module.css";
+
+export function TournamentSummaryRow({
+ tournament,
+ className,
+}: {
+ tournament: {
+ tournamentId: number;
+ name: string;
+ logoUrl: string;
+ teamsCount: number | null;
+ tier?: number | null;
+ tentativeTier?: number | null;
+ startTime?: number | null;
+ };
+ className?: string;
+}) {
+ const { t } = useTranslation(["trophies"]);
+ const isHydrated = useHydrated();
+ const formatDistanceToNow = useFormatDistanceToNow();
+ const { formatter } = useDateTimeFormat({
+ day: "numeric",
+ month: "short",
+ year: "numeric",
+ });
+
+ const isUpcoming = Boolean(
+ tournament.startTime &&
+ databaseTimestampToDate(tournament.startTime) > new Date(),
+ );
+
+ return (
+
+
+
+
+ {tournament.name}
+ {tournament.tier ? (
+
+ ) : tournament.tentativeTier ? (
+
+ ) : null}
+ {isUpcoming ? (
+
+ {t("trophies:details.upcoming")}
+
+ ) : null}
+
+
+
+
+ {tournament.teamsCount}
+
+ {tournament.startTime ? (
+ isUpcoming ? (
+
+ {isHydrated
+ ? formatDistanceToNow(tournament.startTime, {
+ addSuffix: true,
+ })
+ : "Placeholder"}
+
+ ) : (
+
+ {formatter.format(tournament.startTime)}
+
+ )
+ ) : null}
+
+
+
+ );
+}
diff --git a/app/features/trophies/components/TrophiesSelector.module.css b/app/features/trophies/components/TrophiesSelector.module.css
new file mode 100644
index 000000000..0031adcfb
--- /dev/null
+++ b/app/features/trophies/components/TrophiesSelector.module.css
@@ -0,0 +1,54 @@
+.list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-2);
+ padding: 0;
+ margin: 0;
+ list-style: none;
+}
+
+.item {
+ display: flex;
+ align-items: center;
+ gap: var(--s-3);
+ padding: var(--s-1) var(--s-2);
+ border: var(--border-style);
+ border-radius: var(--radius-box);
+ background-color: var(--color-bg-high);
+ transition: opacity 0.2s;
+
+ &.isDragging {
+ opacity: 0.5;
+ }
+}
+
+.dragHandle {
+ cursor: grab;
+ user-select: none;
+ color: var(--color-text-high);
+ font-size: var(--font-md);
+ touch-action: none;
+ background: none;
+ border: none;
+ padding: 0;
+ flex-shrink: 0;
+
+ &:active {
+ cursor: grabbing;
+ }
+}
+
+.preview {
+ width: 40px;
+ flex-shrink: 0;
+}
+
+.name {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: var(--font-sm);
+ font-weight: var(--weight-semi);
+}
diff --git a/app/features/trophies/components/TrophiesSelector.tsx b/app/features/trophies/components/TrophiesSelector.tsx
new file mode 100644
index 000000000..e12354f1d
--- /dev/null
+++ b/app/features/trophies/components/TrophiesSelector.tsx
@@ -0,0 +1,177 @@
+import type { DragEndEvent } from "@dnd-kit/core";
+import {
+ closestCenter,
+ DndContext,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+} from "@dnd-kit/core";
+import {
+ SortableContext,
+ sortableKeyboardCoordinates,
+ useSortable,
+ verticalListSortingStrategy,
+} from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+import clsx from "clsx";
+import { Trash } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import { SendouButton } from "~/components/elements/Button";
+import styles from "./TrophiesSelector.module.css";
+import { Trophy, TrophyContextProvider } from "./Trophy";
+import type { TrophyDisplayProps } from "./TrophyDisplay";
+
+type TrophyItem = TrophyDisplayProps["trophies"][number];
+
+export function TrophiesSelector({
+ options,
+ selectedTrophies,
+ onChange,
+ onBlur,
+ maxCount,
+}: {
+ options: TrophyDisplayProps["trophies"];
+ selectedTrophies: number[];
+ onChange: (newTrophies: number[]) => void;
+ onBlur?: () => void;
+ maxCount?: number;
+}) {
+ const { t } = useTranslation(["common"]);
+
+ const selectedTrophiesInOrder = selectedTrophies
+ .map((id) => options.find((trophy) => trophy.id === id))
+ .filter((trophy): trophy is TrophyItem => trophy !== undefined);
+
+ const sensors = useSensors(
+ useSensor(PointerSensor),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ }),
+ );
+
+ const handleRemove = (trophyId: number) => {
+ onChange(selectedTrophies.filter((id) => id !== trophyId));
+ };
+
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event;
+ if (!over || active.id === over.id) return;
+
+ const oldIndex = selectedTrophies.indexOf(active.id as number);
+ const newIndex = selectedTrophies.indexOf(over.id as number);
+ if (oldIndex === -1 || newIndex === -1) return;
+
+ const newOrder = [...selectedTrophies];
+ const [removed] = newOrder.splice(oldIndex, 1);
+ newOrder.splice(newIndex, 0, removed);
+ onChange(newOrder);
+ };
+
+ return (
+
+ {selectedTrophiesInOrder.length > 0 ? (
+
+
+ trophy.id)}
+ strategy={verticalListSortingStrategy}
+ >
+
+ {selectedTrophiesInOrder.map((trophy) => (
+
+ ))}
+
+
+
+
+ ) : (
+
+ {t("common:trophies.selector.none")}
+
+ )}
+ {options.length === 0 ? (
+
+ {t("common:trophies.selector.noneAvailable")}
+
+ ) : (
+
onBlur?.()}
+ onChange={(e) =>
+ onChange([...selectedTrophies, Number(e.target.value)])
+ }
+ disabled={Boolean(maxCount && selectedTrophies.length >= maxCount)}
+ data-testid="trophies-selector"
+ >
+ {t("common:trophies.selector.select")}
+ {options
+ .filter((trophy) => !selectedTrophies.includes(trophy.id))
+ .map((trophy) => (
+
+ {trophy.name}
+
+ ))}
+
+ )}
+
+ );
+}
+
+function SortableTrophyItem({
+ trophy,
+ onRemove,
+}: {
+ trophy: TrophyItem;
+ onRemove: (id: number) => void;
+}) {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id: trophy.id });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ };
+
+ return (
+
+
+ ☰
+
+
+
+
+ {trophy.name}
+ }
+ aria-label="Remove"
+ onPress={() => onRemove(trophy.id)}
+ />
+
+ );
+}
diff --git a/app/features/trophies/components/Trophy.module.css b/app/features/trophies/components/Trophy.module.css
new file mode 100644
index 000000000..0dfd2a289
--- /dev/null
+++ b/app/features/trophies/components/Trophy.module.css
@@ -0,0 +1,73 @@
+.container {
+ position: relative;
+}
+
+.tile {
+ border: var(--border-width) solid var(--tier-bg);
+ border-radius: var(--radius-field);
+ background-color: var(--color-bg-high);
+}
+
+.grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, 100px);
+ justify-content: center;
+ gap: var(--s-2);
+}
+
+.placeholder {
+ width: 100%;
+ aspect-ratio: 1 / 1;
+}
+
+.tierPill {
+ position: absolute;
+ top: -1px;
+ right: -1px;
+
+ > * {
+ border-radius: 0 var(--radius-field) 0 var(--radius-field);
+ }
+}
+
+.cornerPill {
+ position: absolute;
+ bottom: -1px;
+ left: -1px;
+ display: flex;
+ align-items: center;
+ height: var(--selector-size-sm);
+ padding: 0 var(--s-1-5);
+ border-radius: 0 var(--radius-field) 0 var(--radius-field);
+ background-color: var(--tier-bg, var(--color-bg-higher));
+ color: var(--tier-text, var(--color-text-high));
+ font-size: var(--font-2xs);
+ font-weight: var(--weight-bold);
+}
+
+.trophy {
+ width: 100% !important;
+ height: auto !important;
+ aspect-ratio: 1 / 1;
+ opacity: 1;
+ transition: opacity 0.2s ease;
+
+ @starting-style {
+ opacity: 0;
+ }
+}
+
+.interactive {
+ cursor: grab;
+
+ &:active {
+ cursor: grabbing;
+ }
+}
+
+.error {
+ color: var(--color-text-high);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
diff --git a/app/features/trophies/components/Trophy.tsx b/app/features/trophies/components/Trophy.tsx
new file mode 100644
index 000000000..e8116479b
--- /dev/null
+++ b/app/features/trophies/components/Trophy.tsx
@@ -0,0 +1,255 @@
+import { clsx } from "clsx";
+import { Ban } from "lucide-react";
+import { PicoCAD2Context, PicoCAD2Viewer } from "picocad2-web";
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
+import { TierPill } from "~/components/TierPill";
+import { IS_E2E_TEST_RUN } from "~/utils/e2e";
+import { decompressTrophyModel } from "../trophies-utils";
+import style from "./Trophy.module.css";
+
+type TrophyCtxValue =
+ | { context: PicoCAD2Context }
+ | { context: undefined; isLoading: true };
+
+const TrophyCtx = createContext(undefined);
+
+/**
+ * Shares one PicoCAD2 WebGL context across every `Trophy` rendered inside.
+ *
+ * `Trophy` falls back to creating its own internal context when no provider is
+ * used, but browsers cap active WebGL contexts at 16 so any grid bigger than
+ * that, or rapid mounting/unmounting, triggers a warning and broken rendering.
+ *
+ * We use two implementations to stay under the limit:
+ *
+ * 1. One page wide `PicoCAD2Context` singleton.
+ * Creating a new one per mount can stack contexts faster than the browser
+ * can remove them. Holding one for the page lifetime is cheaper in comparison.
+ *
+ * 2. The provider always renders its children so surrounding layout doesn't shift
+ * but uses an additional `isLoading` bool while `useState` is still `undefined`
+ * before the first effect fires. Descendant `Trophy` components read the bool
+ * and render an empty spacer instead of mounting a canvas that would create
+ * their own internal context.
+ */
+
+let sharedContext: PicoCAD2Context | undefined;
+
+function getSharedTrophyContext() {
+ if (typeof window === "undefined") return undefined;
+ if (!sharedContext) sharedContext = new PicoCAD2Context();
+ return sharedContext;
+}
+
+export function TrophyContextProvider({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const [context, setContext] = useState();
+
+ useEffect(() => {
+ setContext(getSharedTrophyContext());
+ }, []);
+
+ const value: TrophyCtxValue = context
+ ? { context }
+ : { context: undefined, isLoading: true };
+
+ return {children} ;
+}
+
+export function TrophyGrid({ children }: { children: React.ReactNode }) {
+ return {children}
;
+}
+
+export function TrophyPlaceholder() {
+ return
;
+}
+
+export function Trophy({
+ model,
+ className,
+ preview,
+ tile,
+ tier,
+ tentativeTier,
+ disableCameraControls,
+ staticOnSoftwareRendering,
+ pill,
+}: {
+ model: string;
+ className?: string;
+ preview?: boolean;
+ tile?: boolean;
+ tier?: number | null;
+ tentativeTier?: number | null;
+ disableCameraControls?: boolean;
+ staticOnSoftwareRendering?: boolean;
+ pill?: React.ReactNode;
+}) {
+ const ctxValue = useContext(TrophyCtx);
+ const context = ctxValue?.context;
+ const isLoadingSharedContext =
+ ctxValue !== undefined && ctxValue.context === undefined;
+ const viewerRef = useRef(null);
+ const [error, setError] = useState(false);
+
+ const prevModelRef = useRef(model);
+ if (prevModelRef.current !== model) {
+ prevModelRef.current = model;
+ setError(false);
+ }
+
+ const modelState = decompressTrophyModel(model);
+
+ // useCallback is needed to keep the ref callback identity stable.
+ // A new identity each render would make React detach and re-attach,
+ // disposing and rebuilding the viewer on every re-render
+ const canvasRef = useCallback(
+ (canvas: HTMLCanvasElement | null) => {
+ if (!canvas) {
+ viewerRef.current?.dispose();
+ viewerRef.current = null;
+ return;
+ }
+
+ if (modelState === null) return;
+
+ const viewer = new PicoCAD2Viewer({
+ canvas,
+ context,
+ resolution: { width: 128, height: 128, scale: 4 },
+ });
+ viewerRef.current = viewer;
+
+ try {
+ viewer.setState(JSON.parse(modelState));
+ } catch (_) {
+ setError(true);
+ return;
+ }
+
+ // continuous render loops starve the main thread when WebGL is
+ // software rendered, so e2e runs (which always render on CPU) draw a
+ // single static frame, as do surfaces showing many loops at once on
+ // devices without GPU acceleration
+ if (
+ preview ||
+ IS_E2E_TEST_RUN ||
+ (staticOnSoftwareRendering && isSoftwareRendering())
+ ) {
+ viewer.draw();
+ viewer.dispose();
+ return;
+ }
+
+ viewer.cameraMode = "spin";
+ viewer.cameraModeSpeed = 5;
+ viewer.startRenderLoop(false);
+
+ if (disableCameraControls) return;
+
+ viewer.enableCameraControls({
+ spinInertiaFactor: 0.95,
+ pan: false,
+ rotate: true,
+ zoom: true,
+ useFixedOnInteract: {
+ enabled: true,
+ delayBeforeRestore: 1000,
+ restoreTime: 1000,
+ },
+ });
+ },
+ [
+ modelState,
+ context,
+ preview,
+ staticOnSoftwareRendering,
+ disableCameraControls,
+ ],
+ );
+
+ const effectiveTier = tier ?? tentativeTier ?? null;
+ const containerClassName = clsx(style.container, className, {
+ [style.tile]: tile,
+ });
+ const containerStyle = effectiveTier
+ ? ({
+ "--tier-bg": `var(--tier-bg-${effectiveTier})`,
+ "--tier-text": `var(--tier-text-${effectiveTier})`,
+ } as React.CSSProperties)
+ : undefined;
+
+ const tierPill = tier ? (
+
+
+
+ ) : tentativeTier ? (
+
+
+
+ ) : null;
+
+ const cornerPill = pill ? (
+
+ {pill}
+
+ ) : null;
+
+ if (error || modelState === null) {
+ return (
+
+
+
+
+ {tierPill}
+ {cornerPill}
+
+ );
+ }
+
+ if (isLoadingSharedContext) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {tierPill}
+ {cornerPill}
+
+ );
+}
+
+let softwareRenderingDetected: boolean | undefined;
+
+function isSoftwareRendering() {
+ if (softwareRenderingDetected !== undefined) return softwareRenderingDetected;
+ if (typeof document === "undefined") return false;
+
+ const gl = document
+ .createElement("canvas")
+ .getContext("webgl2", { failIfMajorPerformanceCaveat: true });
+ softwareRenderingDetected = !gl;
+ gl?.getExtension("WEBGL_lose_context")?.loseContext();
+
+ return softwareRenderingDetected;
+}
diff --git a/app/features/trophies/components/TrophyDisplay.module.css b/app/features/trophies/components/TrophyDisplay.module.css
new file mode 100644
index 000000000..b8109d8bb
--- /dev/null
+++ b/app/features/trophies/components/TrophyDisplay.module.css
@@ -0,0 +1,112 @@
+.root {
+ display: flex;
+ flex-direction: column;
+ margin-inline: auto;
+ width: max-content;
+}
+
+.grid {
+ display: grid;
+ grid-template-columns: repeat(3, 100px);
+ justify-content: center;
+ gap: var(--s-2);
+ padding: var(--s-2);
+ border: var(--border-style);
+ border-radius: var(--radius-box);
+}
+
+.trophyButton {
+ all: unset;
+ cursor: pointer;
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.win {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-1-5);
+ opacity: 1;
+ transition: opacity 0.2s ease;
+
+ @starting-style {
+ opacity: 0;
+ }
+}
+
+.winHeader {
+ margin-top: var(--s-2);
+}
+
+.winDetails {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-2);
+ padding-inline: var(--s-2);
+}
+
+.winMembers {
+ display: flex;
+ flex-direction: column;
+}
+
+.winMember {
+ display: grid;
+ grid-template-columns: 100px 1fr max-content;
+ align-items: center;
+ gap: var(--s-2);
+ font-size: var(--font-xs);
+ border-radius: var(--radius-field);
+ padding: var(--s-1);
+
+ & > * {
+ min-width: 0;
+ max-width: 100%;
+ }
+
+ &:nth-child(odd) {
+ background-color: var(--color-bg-high);
+ }
+}
+
+@container (width >= 560px) and (width <= 750px) {
+ .winMember {
+ grid-template-columns: 1fr 1fr;
+ grid-template-rows: auto auto;
+ justify-items: center;
+
+ & > button {
+ grid-column: 1 / -1;
+ }
+ }
+}
+
+.winMemberUser {
+ display: flex;
+ align-items: center;
+ gap: var(--s-1-5);
+ font-weight: var(--weight-semi);
+ min-width: 0;
+}
+
+.winMemberName {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.winMemberWeapons {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: flex-end;
+ gap: var(--s-1);
+}
+
+.specialDescription {
+ font-size: var(--font-sm);
+ color: var(--color-text-high);
+ padding-top: var(--s-2);
+}
diff --git a/app/features/trophies/components/TrophyDisplay.tsx b/app/features/trophies/components/TrophyDisplay.tsx
new file mode 100644
index 000000000..4efcf3b5a
--- /dev/null
+++ b/app/features/trophies/components/TrophyDisplay.tsx
@@ -0,0 +1,211 @@
+import clsx from "clsx";
+import * as React from "react";
+import { useTranslation } from "react-i18next";
+import { useFetcher } from "react-router";
+import { Avatar } from "~/components/Avatar";
+import { Divider } from "~/components/Divider";
+import { DotPagination } from "~/components/DotPagination";
+import { WeaponImage } from "~/components/Image";
+import { UserCard } from "~/features/user-card/components/UserCard";
+import { ParticipationPill } from "~/features/user-page/components/ParticipationPill";
+import { usePagination } from "~/hooks/usePagination";
+import { trophyWinsPage } from "~/utils/urls";
+import type { TrophyWinsLoaderData } from "../routes/trophies.$id.wins.$userId";
+import { SMALL_TROPHIES_PER_DISPLAY_PAGE } from "../trophies-constants";
+import {
+ parseSpecialTrophyCode,
+ useProgressiveRender,
+} from "../trophies-utils";
+import { TournamentSummaryRow } from "./TournamentSummaryRow";
+import { Trophy, TrophyContextProvider, TrophyPlaceholder } from "./Trophy";
+import styles from "./TrophyDisplay.module.css";
+import { TrophyShowcaseModal } from "./TrophyShowcase";
+
+type TrophyItem = {
+ id: number;
+ name: string;
+ model: string;
+ tier?: number | null;
+ code?: string | null;
+ count?: number | null;
+};
+
+export interface TrophyDisplayProps {
+ trophies: Array;
+ userId: number;
+ className?: string;
+}
+
+export function TrophyDisplay({
+ trophies,
+ userId,
+ className,
+}: TrophyDisplayProps) {
+ const [openTrophy, setOpenTrophy] = React.useState(null);
+
+ const {
+ itemsToDisplay,
+ everythingVisible,
+ currentPage,
+ pagesCount,
+ setPage,
+ } = usePagination({
+ items: trophies,
+ pageSize: SMALL_TROPHIES_PER_DISPLAY_PAGE,
+ scrollToTop: false,
+ });
+
+ const visibleCount = useProgressiveRender(
+ itemsToDisplay.length,
+ String(currentPage),
+ );
+
+ if (trophies.length === 0) return null;
+
+ return (
+
+
+
+ {itemsToDisplay.map((trophy, i) =>
+ i < visibleCount ? (
+ setOpenTrophy(trophy)}
+ aria-label={trophy.name}
+ >
+ 1
+ ? `×${trophy.count}`
+ : undefined
+ }
+ />
+
+ ) : (
+
+ ),
+ )}
+
+ {!everythingVisible ? (
+
+ ) : null}
+
+ {openTrophy ? (
+ setOpenTrophy(null)}
+ />
+ ) : null}
+
+ );
+}
+
+function TrophyModal({
+ trophy,
+ userId,
+ onClose,
+}: {
+ trophy: TrophyItem;
+ userId: number;
+ onClose: () => void;
+}) {
+ const { t } = useTranslation(["trophies"]);
+ const fetcher = useFetcher();
+ const data = fetcher.data;
+
+ const special = parseSpecialTrophyCode(trophy.code);
+
+ const loadedRef = React.useRef(false);
+ React.useEffect(() => {
+ if (parseSpecialTrophyCode(trophy.code) || loadedRef.current) return;
+ loadedRef.current = true;
+ fetcher.load(trophyWinsPage({ trophyId: trophy.id, userId }));
+ }, [fetcher.load, trophy.id, trophy.code, userId]);
+
+ return (
+
+ {special ? (
+
+
+
+ {special.type === "supporter"
+ ? t("trophies:special.supporter.description")
+ : t("trophies:special.xp.description", {
+ value: special.value,
+ })}
+
+
+ ) : null}
+ {data
+ ? data.wins.map((win) => (
+
+ ))
+ : null}
+
+ );
+}
+
+function TrophyWinDetails({
+ win,
+ userCards,
+}: {
+ win: TrophyWinsLoaderData["wins"][number];
+ userCards: TrophyWinsLoaderData["userCards"];
+}) {
+ return (
+
+
+
+ {win.members.length > 0 ? (
+
+ {win.members.map((member) => (
+
+
+
+
+
+ {member.username}
+
+
+
+
+ {member.weapons.map((weaponSplId) => (
+
+ ))}
+
+
+
+ ))}
+
+ ) : null}
+
+
+ );
+}
diff --git a/app/features/trophies/components/TrophyShowcase.module.css b/app/features/trophies/components/TrophyShowcase.module.css
new file mode 100644
index 000000000..271384493
--- /dev/null
+++ b/app/features/trophies/components/TrophyShowcase.module.css
@@ -0,0 +1,47 @@
+.wrapper {
+ container-type: inline-size;
+}
+
+.modal {
+ padding: 0;
+ max-width: 48rem;
+}
+
+.trophyName {
+ font-weight: var(--weight-bold);
+ font-size: var(--font-lg);
+}
+
+.trophyPageLink {
+ font-size: var(--font-2xs);
+ margin-bottom: var(--s-2);
+}
+
+.content {
+ display: grid;
+ grid-template-columns: 1fr;
+ align-items: center;
+ background-color: var(--color-bg-high);
+}
+
+.details {
+ padding: var(--s-4);
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-2);
+ background-color: var(--color-bg);
+ border-top: var(--border-style);
+ overflow-y: auto;
+}
+
+@container (width >= 560px) {
+ .content {
+ grid-template-columns: 60% 1fr;
+ }
+
+ .details {
+ border-top: 0;
+ border-left: var(--border-style);
+ height: 60cqw;
+ }
+}
diff --git a/app/features/trophies/components/TrophyShowcase.tsx b/app/features/trophies/components/TrophyShowcase.tsx
new file mode 100644
index 000000000..8674af517
--- /dev/null
+++ b/app/features/trophies/components/TrophyShowcase.tsx
@@ -0,0 +1,63 @@
+import clsx from "clsx";
+import type * as React from "react";
+import { useTranslation } from "react-i18next";
+import { Link } from "react-router";
+import { SendouDialog } from "~/components/elements/Dialog";
+import { trophyPage } from "~/utils/urls";
+import { Trophy } from "./Trophy";
+import styles from "./TrophyShowcase.module.css";
+
+export function TrophyShowcase({
+ model,
+ children,
+ className,
+ detailsClassName,
+}: {
+ model: string;
+ children: React.ReactNode;
+ className?: string;
+ detailsClassName?: string;
+}) {
+ return (
+
+ );
+}
+
+export function TrophyShowcaseModal({
+ trophy,
+ onClose,
+ children,
+}: {
+ trophy: { id: number; name: string; model: string };
+ onClose: () => void;
+ children?: React.ReactNode;
+}) {
+ const { t } = useTranslation(["trophies"]);
+
+ return (
+
+
+
+
{trophy.name}
+
+ {t("trophies:display.viewTrophyPage")}
+
+
+ {children}
+
+
+ );
+}
diff --git a/app/features/trophies/components/TrophyTournamentHistory.module.css b/app/features/trophies/components/TrophyTournamentHistory.module.css
new file mode 100644
index 000000000..bc29f5488
--- /dev/null
+++ b/app/features/trophies/components/TrophyTournamentHistory.module.css
@@ -0,0 +1,11 @@
+.list {
+ display: flex;
+ flex-direction: column;
+ padding: 0;
+ margin: 0;
+ gap: var(--s-1);
+
+ & > li {
+ list-style: none;
+ }
+}
diff --git a/app/features/trophies/components/TrophyTournamentHistory.tsx b/app/features/trophies/components/TrophyTournamentHistory.tsx
new file mode 100644
index 000000000..71924cfca
--- /dev/null
+++ b/app/features/trophies/components/TrophyTournamentHistory.tsx
@@ -0,0 +1,19 @@
+import type { TrophyTournamentsLoaderData } from "../routes/trophies.$id.tournaments";
+import { TournamentSummaryRow } from "./TournamentSummaryRow";
+import styles from "./TrophyTournamentHistory.module.css";
+
+export function TrophyTournamentHistory({
+ tournaments,
+}: {
+ tournaments: TrophyTournamentsLoaderData["tournaments"];
+}) {
+ return (
+
+ {tournaments.map((tournament) => (
+
+
+
+ ))}
+
+ );
+}
diff --git a/app/features/trophies/core/model-analysis.ts b/app/features/trophies/core/model-analysis.ts
new file mode 100644
index 000000000..83eca936e
--- /dev/null
+++ b/app/features/trophies/core/model-analysis.ts
@@ -0,0 +1,110 @@
+import type { Color3, ExtrasOptions, ViewerSettings } from "picocad2-web";
+
+export interface TrophyModelAnalysis {
+ cameraTargetCentered: boolean;
+ backgroundIsAlpha: boolean;
+ drawCalls: number;
+ polyCount: number;
+ effectsCount: number;
+}
+
+type RawColor = Array | { r: number; g: number; b: number };
+
+interface RawFace {
+ vertex_ids: Array;
+ dbl?: boolean;
+ prio?: boolean;
+}
+
+interface RawGraphNode {
+ visible: boolean;
+ children: Array;
+ mesh?: { faces: Array };
+}
+
+interface ModelState {
+ source: {
+ graph: RawGraphNode;
+ texture: {
+ colors: Array;
+ background_color: number;
+ transparent_color: number;
+ };
+ };
+ settings: ViewerSettings;
+ extras?: ExtrasOptions;
+}
+
+export function analyzeTrophyModel(model: string): TrophyModelAnalysis | null {
+ try {
+ const state: ModelState = JSON.parse(model);
+
+ return {
+ cameraTargetCentered: isCameraTargetCentered(state),
+ backgroundIsAlpha: isBackgroundAlpha(state),
+ ...countGeometry(state),
+ effectsCount: countEnabledEffects(state),
+ };
+ } catch {
+ return null;
+ }
+}
+
+function isCameraTargetCentered(state: ModelState) {
+ const target = state.settings.camera.target;
+ return target[0] === 0 && target[2] === 0;
+}
+
+function isBackgroundAlpha(state: ModelState) {
+ const texture = state.source.texture;
+ const transparent = toColor3(texture.colors[texture.transparent_color]);
+ const background =
+ state.settings.backgroundColor ??
+ toColor3(texture.colors[texture.background_color]);
+
+ return (
+ background[0] === transparent[0] &&
+ background[1] === transparent[1] &&
+ background[2] === transparent[2]
+ );
+}
+
+function toColor3(color: RawColor): Color3 {
+ return Array.isArray(color)
+ ? [color[0], color[1], color[2]]
+ : [color.r, color.g, color.b];
+}
+
+function countGeometry(state: ModelState) {
+ const counts = { drawCalls: 0, polyCount: 0 };
+
+ const visit = (node: RawGraphNode, parentVisible: boolean) => {
+ for (const child of node.children) {
+ const visible = Boolean(child.visible) && parentVisible;
+
+ if (visible && child.mesh) {
+ const groups = new Set();
+
+ for (const face of child.mesh.faces) {
+ if (face.vertex_ids.length < 3) continue;
+
+ groups.add((face.dbl ? 1 : 0) | (face.prio ? 2 : 0));
+ counts.polyCount += face.vertex_ids.length - 2;
+ }
+
+ counts.drawCalls += groups.size;
+ }
+
+ visit(child, visible);
+ }
+ };
+ visit(state.source.graph, true);
+
+ return counts;
+}
+
+function countEnabledEffects(state: ModelState) {
+ return Object.values(state.extras ?? {}).filter(
+ (effect) => effect?.enabled === true,
+ ).length;
+}
diff --git a/app/features/trophies/loaders/trophies.$id.server.ts b/app/features/trophies/loaders/trophies.$id.server.ts
new file mode 100644
index 000000000..59dd2e2c5
--- /dev/null
+++ b/app/features/trophies/loaders/trophies.$id.server.ts
@@ -0,0 +1,27 @@
+import type { LoaderFunctionArgs } from "react-router";
+import { getUser } from "~/features/auth/core/user.server";
+import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
+import { idObject } from "~/utils/zod";
+import * as TrophyRepository from "../TrophyRepository.server";
+import { canAccessTrophies } from "../trophies-utils";
+
+export const loader = async ({ params }: LoaderFunctionArgs) => {
+ if (!canAccessTrophies(getUser())) {
+ throw new Response(null, { status: 404 });
+ }
+
+ const { id } = parseParams({
+ params,
+ schema: idObject,
+ });
+
+ const [trophy, tournaments] = await Promise.all([
+ TrophyRepository.findById(id).then(notFoundIfNullish),
+ TrophyRepository.findTournamentsByTrophyId(id),
+ ]);
+
+ return {
+ trophy,
+ tournaments,
+ };
+};
diff --git a/app/features/trophies/loaders/trophies.new.server.ts b/app/features/trophies/loaders/trophies.new.server.ts
new file mode 100644
index 000000000..59d8f5809
--- /dev/null
+++ b/app/features/trophies/loaders/trophies.new.server.ts
@@ -0,0 +1,67 @@
+import type { LoaderFunctionArgs } from "react-router";
+import { requireUser } from "~/features/auth/core/user.server";
+import type { SerializeFrom } from "~/utils/remix";
+import * as TrophyRepository from "../TrophyRepository.server";
+import { TROPHY_APPROVALS_REQUIRED } from "../trophies-constants";
+import {
+ canAccessTrophies,
+ canEditAnyTrophy,
+ canReviewTrophies,
+} from "../trophies-utils";
+
+export type NewTrophyLoaderData = SerializeFrom;
+
+export const loader = async (_args: LoaderFunctionArgs) => {
+ const user = requireUser();
+ if (!canAccessTrophies(user)) {
+ throw new Response(null, { status: 404 });
+ }
+
+ const canReview = canReviewTrophies(user);
+
+ const [rawItems, ownUnreviewedCount, editableTrophies] = await Promise.all([
+ canReview
+ ? TrophyRepository.allPending()
+ : TrophyRepository.pendingBySubmitter(user.id),
+ TrophyRepository.unreviewedCountBySubmitter(user.id),
+ canEditAnyTrophy(user)
+ ? TrophyRepository.findAllForEditing()
+ : TrophyRepository.findManagedBy(user.id),
+ ]);
+
+ const allItems = canReview ? rawItems : rawItems.map(stripReviewerInfo);
+
+ const isAccepted = (item: (typeof allItems)[number]) =>
+ item.approvals.length >= TROPHY_APPROVALS_REQUIRED;
+
+ const pendingTrophies = allItems.filter(
+ (item) => !isAccepted(item) && !item.declinedAt,
+ );
+ const reviewedTrophies = allItems.filter(
+ (item) => isAccepted(item) || item.declinedAt,
+ );
+
+ return {
+ canReview,
+ currentUserId: user.id,
+ ownUnreviewedCount,
+ pendingTrophies,
+ reviewedTrophies,
+ editableTrophies,
+ };
+};
+
+function stripReviewerInfo(
+ item: Awaited>[number],
+) {
+ return {
+ ...item,
+ approvals: item.approvals.map(() => ({
+ userId: null as number | null,
+ username: null as string | null,
+ createdAt: 0,
+ })),
+ declinedByUserId: null,
+ declinedByUsername: null,
+ };
+}
diff --git a/app/features/trophies/loaders/trophies.server.ts b/app/features/trophies/loaders/trophies.server.ts
new file mode 100644
index 000000000..34f94d587
--- /dev/null
+++ b/app/features/trophies/loaders/trophies.server.ts
@@ -0,0 +1,28 @@
+import { cachified } from "@epic-web/cachified";
+import { getUser } from "~/features/auth/core/user.server";
+import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server";
+import * as TrophyRepository from "../TrophyRepository.server";
+import { canAccessTrophies } from "../trophies-utils";
+
+const TROPHIES_CACHE_KEY = "trophies";
+
+export const loader = async () => {
+ if (!canAccessTrophies(getUser())) {
+ throw new Response(null, { status: 404 });
+ }
+
+ const trophies = await cachified({
+ key: TROPHIES_CACHE_KEY,
+ cache,
+ ttl: ttl(IN_MILLISECONDS.TWO_HOURS),
+ async getFreshValue() {
+ return TrophyRepository.all();
+ },
+ });
+
+ return { trophies };
+};
+
+export function clearTrophiesCache() {
+ cache.delete(TROPHIES_CACHE_KEY);
+}
diff --git a/app/features/trophies/routes/trophies.$id.tournaments.ts b/app/features/trophies/routes/trophies.$id.tournaments.ts
new file mode 100644
index 000000000..f668fd515
--- /dev/null
+++ b/app/features/trophies/routes/trophies.$id.tournaments.ts
@@ -0,0 +1,24 @@
+import type { LoaderFunctionArgs } from "react-router";
+import { getUser } from "~/features/auth/core/user.server";
+import type { SerializeFrom } from "~/utils/remix";
+import { parseParams } from "~/utils/remix.server";
+import { idObject } from "~/utils/zod";
+import * as TrophyRepository from "../TrophyRepository.server";
+import { canAccessTrophies } from "../trophies-utils";
+
+export type TrophyTournamentsLoaderData = SerializeFrom;
+
+export const loader = async ({ params }: LoaderFunctionArgs) => {
+ if (!canAccessTrophies(getUser())) {
+ throw new Response(null, { status: 404 });
+ }
+
+ const { id } = parseParams({
+ params,
+ schema: idObject,
+ });
+
+ return {
+ tournaments: await TrophyRepository.findTournamentsByTrophyId(id),
+ };
+};
diff --git a/app/features/trophies/routes/trophies.$id.tsx b/app/features/trophies/routes/trophies.$id.tsx
new file mode 100644
index 000000000..364431e0b
--- /dev/null
+++ b/app/features/trophies/routes/trophies.$id.tsx
@@ -0,0 +1,145 @@
+import clsx from "clsx";
+import { Trans, useTranslation } from "react-i18next";
+import { Link, type MetaFunction, useLoaderData } from "react-router";
+import { Divider } from "~/components/Divider";
+import { metaTags, type SerializeFrom } from "~/utils/remix";
+import { tournamentOrganizationPage, userPage } from "~/utils/urls";
+import { TrophyShowcase } from "../components/TrophyShowcase";
+import { TrophyTournamentHistory } from "../components/TrophyTournamentHistory";
+import { loader } from "../loaders/trophies.$id.server";
+import { parseSpecialTrophyCode } from "../trophies-utils";
+import styles from "./trophies.module.css";
+
+export { loader };
+
+export const meta: MetaFunction = (args) => {
+ const data = args.loaderData as SerializeFrom | null;
+
+ if (!data) return [];
+
+ const ownerCount = data.trophy.owners.reduce(
+ (sum, owner) => sum + owner.count,
+ 0,
+ );
+
+ return metaTags({
+ title: data.trophy.name,
+ ogTitle: `${data.trophy.name} (Splatoon trophy)`,
+ description: `See who owns the ${data.trophy.name} trophy on sendou.ink. Awarded ${ownerCount} time${ownerCount === 1 ? "" : "s"} so far.`,
+ location: args.location,
+ });
+};
+
+export default function TrophyDetailsPage() {
+ const { t } = useTranslation(["trophies"]);
+ const data = useLoaderData();
+ const { trophy, tournaments } = data;
+
+ const special = parseSpecialTrophyCode(trophy.code);
+
+ return (
+
+
+
{trophy.name}
+ {trophy.organization ? (
+
+
+ Organization:
+
+ {trophy.organization.name}
+
+
+
+ ) : null}
+ {trophy.creator ? (
+
+
+ Created by
+
+ {trophy.creator.username}
+
+
+
+ ) : null}
+ {trophy.manager ? (
+
+
+ Managed by
+
+ {trophy.manager.username}
+
+
+
+ ) : null}
+ {special ? (
+
+ {special.type === "supporter"
+ ? t("trophies:special.supporter.description")
+ : t("trophies:special.xp.description", {
+ value: special.value,
+ })}
+
+ ) : null}
+
+ {special ? null : (
+
+
+ {t("trophies:details.tournamentHistory")}
+
+ {tournaments.length > 0 ? (
+
+ ) : (
+
+ {t("trophies:details.noTournamentHistory")}
+
+ )}
+
+ )}
+
+
+ {t("trophies:details.owners")}
+
+ {trophy.owners.length > 0 ? (
+
+ {trophy.owners.map((owner) => (
+
+ {owner.username}
+
+ ×{owner.count}
+
+
+ ))}
+
+ ) : (
+
+ {t("trophies:details.noOwners")}
+
+ )}
+
+
+ );
+}
diff --git a/app/features/trophies/routes/trophies.$id.wins.$userId.ts b/app/features/trophies/routes/trophies.$id.wins.$userId.ts
new file mode 100644
index 000000000..c7e7ed498
--- /dev/null
+++ b/app/features/trophies/routes/trophies.$id.wins.$userId.ts
@@ -0,0 +1,33 @@
+import type { LoaderFunctionArgs } from "react-router";
+import * as R from "remeda";
+import { z } from "zod";
+import { getUser } from "~/features/auth/core/user.server";
+import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
+import type { SerializeFrom } from "~/utils/remix";
+import { parseParams } from "~/utils/remix.server";
+import { id } from "~/utils/zod";
+import * as TrophyRepository from "../TrophyRepository.server";
+import { canAccessTrophies } from "../trophies-utils";
+
+export type TrophyWinsLoaderData = SerializeFrom;
+
+const paramsSchema = z.object({ id, userId: id });
+
+export const loader = async ({ params }: LoaderFunctionArgs) => {
+ if (!canAccessTrophies(getUser())) {
+ throw new Response(null, { status: 404 });
+ }
+
+ const { id: trophyId, userId } = parseParams({
+ params,
+ schema: paramsSchema,
+ });
+
+ const wins = await TrophyRepository.findWinsByOwner({ trophyId, userId });
+
+ const { userCards } = await UserCardRepository.findAllByUserIds({
+ userIds: R.unique(wins.flatMap((win) => win.members.map((m) => m.id))),
+ });
+
+ return { wins, userCards };
+};
diff --git a/app/features/trophies/routes/trophies.module.css b/app/features/trophies/routes/trophies.module.css
new file mode 100644
index 000000000..4bcdb1417
--- /dev/null
+++ b/app/features/trophies/routes/trophies.module.css
@@ -0,0 +1,64 @@
+.mainContent {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-6);
+}
+
+.badgesLink {
+ color: var(--color-text-high);
+ font-size: var(--font-xs);
+}
+
+.trophiesListContainer {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-4);
+}
+
+.trophyDetailsContainer {
+ border: var(--border-style);
+ border-radius: var(--radius-box);
+ overflow: clip;
+}
+
+@container (width < 560px) {
+ .trophyDetails {
+ height: 512px;
+ }
+}
+
+.divider {
+ margin-top: var(--s-1);
+}
+
+.trophyName {
+ font-weight: var(--weight-bold);
+ font-size: var(--font-lg);
+}
+
+.trophyMeta {
+ color: var(--color-text-high);
+ font-size: var(--font-2xs);
+}
+
+.owners {
+ display: flex;
+ flex-wrap: wrap;
+ padding: 0;
+ font-size: var(--font-xs);
+ gap: var(--s-2);
+
+ & > li {
+ display: flex;
+ align-items: center;
+ gap: var(--s-1);
+ list-style: none;
+ border-right: var(--border-style);
+ padding-right: var(--s-2);
+ }
+}
+
+.ownerCount {
+ color: var(--color-text-high);
+ font-size: var(--font-2xs);
+}
diff --git a/app/features/trophies/routes/trophies.new.module.css b/app/features/trophies/routes/trophies.new.module.css
new file mode 100644
index 000000000..749e05dfe
--- /dev/null
+++ b/app/features/trophies/routes/trophies.new.module.css
@@ -0,0 +1,323 @@
+.terms {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-4);
+ padding: var(--s-4);
+ border: var(--border-style);
+ border-radius: var(--radius-box);
+ background-color: var(--color-bg-high);
+ font-size: var(--font-sm);
+}
+
+.termsTitle {
+ font-size: var(--font-md);
+ color: var(--color-error-high);
+ background-color: var(--color-error-low);
+ padding: var(--s-2);
+ border-radius: var(--radius-field);
+ border: 1px solid var(--color-error);
+}
+
+.termsGroupTitle {
+ font-weight: var(--weight-semi);
+ margin-bottom: var(--s-1);
+}
+
+.termsList {
+ list-style: disc;
+ padding-inline-start: var(--s-6);
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-1);
+}
+
+.termsDisclaimer {
+ font-weight: var(--weight-bold);
+ margin-bottom: var(--s-1);
+}
+
+.termsAgreeButton {
+ align-self: center;
+}
+
+.modelTextarea {
+ min-height: 160px;
+ font-family: var(--font-mono);
+ font-size: var(--font-xs);
+}
+
+.trophyPreview {
+ border-radius: var(--radius-field);
+ background-color: var(--color-bg-high);
+ margin-top: var(--s-4);
+
+ &:has(div) {
+ width: 100px;
+ height: auto;
+ aspect-ratio: 1 / 1;
+ border: var(--border-width) solid var(--tier-bg);
+ }
+}
+
+.previewThemes {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--s-4);
+ margin-top: var(--s-4);
+}
+
+.previewTheme {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--s-3);
+ padding: var(--s-3);
+ border: var(--border-style);
+ border-radius: var(--radius-field);
+ background-color: var(--color-bg);
+
+ & .trophyPreview {
+ margin-top: 0;
+ width: 100%;
+ }
+}
+
+.previewThemeLabel {
+ font-size: var(--font-xs);
+ font-weight: var(--weight-semi);
+ color: var(--color-text-high);
+}
+
+.modelSpecs {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-3);
+ margin-top: var(--s-4);
+ padding: var(--s-3);
+ border: var(--border-style);
+ border-radius: var(--radius-box);
+ background-color: var(--color-bg-high);
+ font-size: var(--font-sm);
+}
+
+.specList {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-1);
+ padding: 0;
+ margin-top: var(--s-1);
+
+ & > li {
+ list-style: none;
+ }
+}
+
+.specItem {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--s-1-5);
+}
+
+.specIcon {
+ width: 16px;
+ height: 16px;
+ flex-shrink: 0;
+ margin-top: 2px;
+ color: var(--color-text-high);
+}
+
+.specIconPass {
+ color: var(--color-success);
+}
+
+.specIconFail {
+ color: var(--color-error);
+}
+
+.specIconWarn {
+ color: var(--color-warning);
+}
+
+.specDetail {
+ color: var(--color-text-high);
+}
+
+.pendingSpecs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--s-1) var(--s-2);
+ font-size: var(--font-2xs);
+ color: var(--color-text-high);
+}
+
+.pendingSpecsWarn {
+ color: var(--color-warning);
+ font-weight: var(--weight-semi);
+}
+
+.pendingSpecsError {
+ color: var(--color-error);
+ font-weight: var(--weight-semi);
+}
+
+.descriptionTextarea {
+ min-height: 120px;
+}
+
+.updateContainer {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-4);
+}
+
+.diffHeader {
+ font-size: var(--font-2xs);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.diffGrid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--s-4) var(--s-2);
+ padding: var(--s-2);
+ border: var(--border-style);
+ border-radius: var(--radius-field);
+ background-color: var(--color-bg);
+ font-size: var(--font-xs);
+}
+
+.diffField {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.diffLabel {
+ color: var(--color-text-high);
+ font-size: var(--font-2xs);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.diffValue {
+ overflow-wrap: anywhere;
+}
+
+.diffOld {
+ color: var(--color-text-high);
+ text-decoration: line-through;
+}
+
+.editingBadge {
+ padding: 0 var(--s-1-5);
+ border-radius: var(--radius-selector);
+ background-color: var(--color-text-accent);
+ color: var(--color-text-inverse);
+ font-size: var(--font-2xs);
+ font-weight: var(--weight-bold);
+}
+
+.notManagerBadge {
+ padding: 0 var(--s-1-5);
+ border-radius: var(--radius-selector);
+ background-color: var(--color-warning-low);
+ color: var(--color-warning-high);
+ font-size: var(--font-2xs);
+ font-weight: var(--weight-bold);
+}
+
+.pendingList {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-4);
+}
+
+.pendingItem {
+ display: grid;
+ grid-template-columns: 100px 1fr;
+ gap: var(--s-4);
+ padding: var(--s-2);
+ border: var(--border-style);
+ border-radius: var(--radius-box);
+ background-color: var(--color-bg-high);
+ opacity: 1;
+ transition: opacity 0.2s ease;
+
+ @starting-style {
+ opacity: 0;
+ }
+}
+
+.pendingMain {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-2);
+ min-width: 0;
+}
+
+.pendingHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--s-2);
+ flex-wrap: wrap;
+}
+
+.pendingName {
+ font-weight: var(--weight-semi);
+}
+
+.pendingMeta {
+ font-size: var(--font-xs);
+ color: var(--color-text-high);
+}
+
+.pendingDescription {
+ white-space: pre-wrap;
+ font-size: var(--font-sm);
+}
+
+.pendingActions {
+ display: flex;
+ gap: var(--s-2);
+ flex-wrap: wrap;
+}
+
+.declined {
+ background-color: var(--color-error-low);
+ border: 1px solid var(--color-error);
+ border-radius: var(--radius-field);
+ padding: var(--s-2);
+ font-size: var(--font-sm);
+
+ > p {
+ color: var(--color-error-high);
+ font-weight: var(--weight-bold);
+ }
+}
+
+.accepted {
+ background-color: var(--color-success-low);
+ border: 1px solid var(--color-success);
+ border-radius: var(--radius-field);
+ padding: var(--s-2);
+ font-size: var(--font-sm);
+
+ > p {
+ color: var(--color-success-high);
+ font-weight: var(--weight-bold);
+ }
+}
+
+.trophyPreviewButton {
+ all: unset;
+ cursor: pointer;
+}
+
+.dialogForm {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-3);
+ min-width: 280px;
+}
diff --git a/app/features/trophies/routes/trophies.new.tsx b/app/features/trophies/routes/trophies.new.tsx
new file mode 100644
index 000000000..89ad26f39
--- /dev/null
+++ b/app/features/trophies/routes/trophies.new.tsx
@@ -0,0 +1,966 @@
+import clsx from "clsx";
+import { Check, Clipboard, Dot, Trash2, TriangleAlert, X } from "lucide-react";
+import * as React from "react";
+import { Trans, useTranslation } from "react-i18next";
+import {
+ Form,
+ Link,
+ type MetaFunction,
+ useFetcher,
+ useLoaderData,
+} from "react-router";
+import { Alert } from "~/components/Alert";
+import { SendouButton } from "~/components/elements/Button";
+import { SendouDialog } from "~/components/elements/Dialog";
+import { OrganizationSearch } from "~/components/elements/OrganizationSearch";
+import { SendouSelect, SendouSelectItem } from "~/components/elements/Select";
+import {
+ SendouTab,
+ SendouTabList,
+ SendouTabPanel,
+ SendouTabs,
+} from "~/components/elements/Tabs";
+import { UserSearch } from "~/components/elements/UserSearch";
+import { FormMessage } from "~/components/FormMessage";
+import { Label } from "~/components/Label";
+import { Main } from "~/components/Main";
+import type { CustomFieldRenderProps } from "~/form/FormField";
+import { SendouForm } from "~/form/SendouForm";
+import { useDebounce } from "~/hooks/useDebounce";
+import { metaTags } from "~/utils/remix";
+import type { SendouRouteHandle } from "~/utils/remix.server";
+import {
+ navIconUrl,
+ PICOCAD2_WEB_VIEWER_URL,
+ SENDOU_INK_DISCORD_URL,
+ TROPHIES_PAGE,
+ tournamentOrganizationPage,
+ userPage,
+} from "~/utils/urls";
+import { action } from "../actions/trophies.new.server";
+import { Trophy, TrophyContextProvider } from "../components/Trophy";
+import {
+ analyzeTrophyModel,
+ type TrophyModelAnalysis,
+} from "../core/model-analysis";
+import {
+ loader,
+ type NewTrophyLoaderData,
+} from "../loaders/trophies.new.server";
+import {
+ TROPHY_APPROVALS_REQUIRED,
+ TROPHY_DECLINE_REASON_MAX_LENGTH,
+ TROPHY_MODEL_RECOMMENDED_MAX_DRAW_CALLS,
+ TROPHY_MODEL_RECOMMENDED_MAX_EFFECTS,
+ TROPHY_MODEL_RECOMMENDED_MAX_POLYS,
+ TROPHY_PENDING_PER_USER_LIMIT,
+} from "../trophies-constants";
+import {
+ createTrophyFormSchema,
+ updateTrophyFormSchema,
+} from "../trophies-schemas";
+import {
+ compressTrophyModel,
+ decompressTrophyModel,
+ useProgressiveRender,
+ useTrophyTermsAgreement,
+} from "../trophies-utils";
+import styles from "./trophies.new.module.css";
+
+export { action, loader };
+
+export const handle: SendouRouteHandle = {
+ i18n: "trophies",
+ breadcrumb: () => ({
+ imgPath: navIconUrl("trophies"),
+ href: TROPHIES_PAGE,
+ type: "IMAGE",
+ }),
+};
+
+export const meta: MetaFunction = (args) => {
+ return metaTags({
+ title: "New trophy",
+ ogTitle: "Submit a new trophy",
+ location: args.location,
+ description: "Submit a new trophy for review.",
+ });
+};
+
+export default function NewTrophyPage() {
+ const { t } = useTranslation(["trophies"]);
+ const data = useLoaderData();
+
+ return (
+
+
+
+ {t("trophies:new.tabs.upload")}
+
+ {t("trophies:new.tabs.update")}
+
+
+ {t("trophies:new.tabs.pending")}
+
+ {t("trophies:new.tabs.reviewed")}
+
+
+ {data.ownUnreviewedCount >= TROPHY_PENDING_PER_USER_LIMIT ? (
+
+ {t("trophies:new.form.limitReached", {
+ limit: TROPHY_PENDING_PER_USER_LIMIT,
+ })}
+
+ ) : (
+
+
+
+ )}
+
+
+ {data.ownUnreviewedCount >= TROPHY_PENDING_PER_USER_LIMIT ? (
+
+ {t("trophies:new.form.limitReached", {
+ limit: TROPHY_PENDING_PER_USER_LIMIT,
+ })}
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function TrophyTermsGate({ children }: { children: React.ReactNode }) {
+ const { t } = useTranslation(["trophies"]);
+ const { hasAgreedToTerms, agreeToTerms } = useTrophyTermsAgreement();
+
+ if (hasAgreedToTerms) return children;
+
+ return (
+
+
{t("trophies:new.terms.title")}
+
{t("trophies:new.terms.intro")}
+
+
+ {t("trophies:new.terms.oneOff.title")}
+
+
+ {t("trophies:new.terms.trustedOrg")}
+ {t("trophies:new.terms.oneOff.pastTournaments")}
+ {t("trophies:new.terms.oneOff.projectedTeams")}
+ {t("trophies:new.terms.oneOff.signedUpTeams")}
+
+
+
+
+ {t("trophies:new.terms.series.title")}
+
+
+ {t("trophies:new.terms.trustedOrg")}
+ {t("trophies:new.terms.series.consistentTeams")}
+
+
+
+
+ {t("trophies:new.terms.disclaimer")}
+
+
+
+ If you are unsure if your tournament is eligible, feel free to
+ acquire a pre-approval by creating a pre-approval request in the
+
+ Discord server
+
+ .
+
+
+
+
+ {t("trophies:new.terms.agree")}
+
+
+ );
+}
+
+function NewTrophyForm() {
+ return (
+
+ {({ FormField }) => (
+ <>
+
+
+ {({ error, value, onChange }: CustomFieldRenderProps) => (
+
+ )}
+
+
+ {({ name, error, value, onChange }: CustomFieldRenderProps) => (
+
+ )}
+
+
+ >
+ )}
+
+ );
+}
+
+function UpdateTrophyTab() {
+ const { t } = useTranslation(["trophies"]);
+ const data = useLoaderData();
+ const [selectedId, setSelectedId] = React.useState(null);
+
+ const selectedTrophy = data.editableTrophies.find((t) => t.id === selectedId);
+
+ return (
+
+ setSelectedId(key as number)}
+ >
+ {(trophy) => (
+
+ {trophy.name}
+
+ )}
+
+ {selectedTrophy ? (
+
+ ) : null}
+
+ );
+}
+
+function UpdateTrophyForm({
+ trophy,
+}: {
+ trophy: NewTrophyLoaderData["editableTrophies"][number];
+}) {
+ const decompressedModel = decompressTrophyModel(trophy.model) ?? "";
+
+ return (
+
+ {({ FormField }) => (
+ <>
+
+
+ {({ error, value, onChange }: CustomFieldRenderProps) => (
+
+ )}
+
+
+ {({ error, value, onChange }: CustomFieldRenderProps) => (
+
+ )}
+
+
+ {({ name, error, value, onChange }: CustomFieldRenderProps) => (
+
+ )}
+
+
+ >
+ )}
+
+ );
+}
+
+function ManagerField({
+ error,
+ value,
+ onChange,
+}: {
+ error?: string;
+ value: number | null;
+ onChange: (value: number | null) => void;
+}) {
+ const { t } = useTranslation(["forms"]);
+
+ return (
+
+ {t("forms:labels.trophyManager")}
+ onChange(user?.id ?? null)}
+ />
+ {error ? {error} : null}
+
+ );
+}
+
+function ModelField({
+ name,
+ error,
+ value,
+ onChange,
+}: {
+ name: string;
+ error?: string;
+ value: string;
+ onChange: (value: string) => void;
+}) {
+ const { t } = useTranslation(["forms", "trophies"]);
+ const [preview, setPreview] = React.useState(() => buildModelPreview(value));
+
+ useDebounce(() => setPreview(buildModelPreview(value)), 500, [value]);
+
+ return (
+
+
+ {t("forms:labels.trophyModel")}
+
+
+ );
+}
+
+function buildModelPreview(model: string) {
+ if (!model) return { compressedModel: "", analysis: null };
+
+ return {
+ compressedModel: compressTrophyModel(model),
+ analysis: analyzeTrophyModel(model),
+ };
+}
+
+function ModelSpecs({ analysis }: { analysis: TrophyModelAnalysis | null }) {
+ const { t } = useTranslation(["trophies"]);
+
+ const enforcedStatus = (passes: boolean) =>
+ analysis ? (passes ? "pass" : "fail") : null;
+ const recommendedStatus = (withinLimit: boolean) =>
+ analysis ? (withinLimit ? "pass" : "warn") : null;
+
+ return (
+
+
+
+ {t("trophies:new.specs.required")}
+
+
+
+ {t("trophies:new.specs.cameraTarget")}
+
+
+ {t("trophies:new.specs.background")}
+
+ {t("trophies:new.specs.centered")}
+ {t("trophies:new.specs.zoom")}
+ {t("trophies:new.specs.angles")}
+
+
+
+
+ {t("trophies:new.specs.recommended")}
+
+
+
+ {t("trophies:new.specs.drawCalls", {
+ value: TROPHY_MODEL_RECOMMENDED_MAX_DRAW_CALLS,
+ })}
+
+
+ {t("trophies:new.specs.polys", {
+ value: TROPHY_MODEL_RECOMMENDED_MAX_POLYS,
+ })}
+
+
+ {t("trophies:new.specs.effects", {
+ value: TROPHY_MODEL_RECOMMENDED_MAX_EFFECTS,
+ })}
+
+
+
+
+ );
+}
+
+function SpecItem({
+ children,
+ status,
+ detail,
+}: {
+ children: React.ReactNode;
+ status?: "pass" | "fail" | "warn" | null;
+ detail?: string;
+}) {
+ return (
+
+ {status === "pass" ? (
+
+ ) : status === "fail" ? (
+
+ ) : status === "warn" ? (
+
+ ) : (
+
+ )}
+
+ {children}
+ {detail ? {detail} : null}
+
+
+ );
+}
+
+function OrganizationField({
+ error,
+ value,
+ onChange,
+}: {
+ error?: string;
+ value: number | null;
+ onChange: (value: number | null) => void;
+}) {
+ const { t } = useTranslation(["forms"]);
+
+ return (
+
+ {t("forms:labels.trophyOrganization")}
+ onChange(org?.id ?? null)}
+ />
+ {error ? {error} : null}
+
+ );
+}
+
+function TrophyList({
+ items,
+ listKind,
+}: {
+ items: NewTrophyLoaderData["pendingTrophies"];
+ listKind: "pending" | "reviewed";
+}) {
+ const { t } = useTranslation(["trophies"]);
+ const data = useLoaderData();
+ const visibleCount = useProgressiveRender(items.length, listKind);
+
+ if (items.length === 0) {
+ return (
+
+ {listKind === "pending"
+ ? t("trophies:new.pending.empty")
+ : t("trophies:new.reviewed.empty")}
+
+ );
+ }
+
+ return (
+
+
+ {items.slice(0, visibleCount).map((item) => (
+
+ ))}
+
+
+ );
+}
+
+function TrophyListRow({
+ pending,
+ currentUserId,
+ canReview,
+}: {
+ pending: NewTrophyLoaderData["pendingTrophies"][number];
+ currentUserId: number;
+ canReview: boolean;
+}) {
+ const { t } = useTranslation(["trophies", "common"]);
+ const fetcher = useFetcher();
+
+ const isOwner = pending.submitterUserId === currentUserId;
+ const isDeclined = pending.declinedAt !== null;
+ const isAccepted = pending.approvals.length >= TROPHY_APPROVALS_REQUIRED;
+ const isReviewed = isDeclined || isAccepted;
+ const alreadyApproved = pending.approvals.some(
+ (a) => a.userId === currentUserId,
+ );
+
+ const handleDelete = () => {
+ const formData = new FormData();
+ formData.append("_action", "DELETE");
+ formData.append("pendingTrophyId", String(pending.id));
+ fetcher.submit(formData, { method: "post" });
+ };
+
+ const handleApprove = () => {
+ const formData = new FormData();
+ formData.append("_action", "APPROVE");
+ formData.append("pendingTrophyId", String(pending.id));
+ fetcher.submit(formData, { method: "post" });
+ };
+
+ const [previewOpen, setPreviewOpen] = React.useState(false);
+
+ const [analysis] = React.useState(() =>
+ analyzeTrophyModel(decompressTrophyModel(pending.model) ?? ""),
+ );
+
+ return (
+
+
setPreviewOpen(true)}
+ >
+
+
+
setPreviewOpen(false)}
+ showCloseButton
+ >
+
+
+
+
+ {pending.target ? (
+
+ {t("trophies:new.update.editing")}
+
+ ) : null}
+ {pending.target &&
+ pending.submitterUserId !== pending.target.managerId ? (
+
+ {t("trophies:new.pending.notFromManager")} (
+ {pending.submitterUsername})
+
+ ) : null}
+ {pending.name}
+
+ {pending.manager?.discordId ? (
+
+ {pending.manager.username}
+
+ ) : pending.submitterDiscordId ? (
+
+ {pending.submitterUsername}
+
+ ) : (
+ pending.submitterUsername
+ )}
+ {pending.organizationName ? (
+ <>
+ {" • "}
+ {pending.organizationSlug ? (
+
+ {pending.organizationName}
+
+ ) : (
+ pending.organizationName
+ )}
+ >
+ ) : null}
+
+
+ {analysis ? (
+
+ TROPHY_MODEL_RECOMMENDED_MAX_DRAW_CALLS,
+ })}
+ >
+ {t("trophies:new.specs.stats.drawCalls", {
+ value: analysis.drawCalls,
+ })}
+
+ TROPHY_MODEL_RECOMMENDED_MAX_POLYS,
+ })}
+ >
+ {t("trophies:new.specs.stats.polys", {
+ value: analysis.polyCount,
+ })}
+
+ TROPHY_MODEL_RECOMMENDED_MAX_EFFECTS,
+ })}
+ >
+ {t("trophies:new.specs.stats.effects", {
+ value: analysis.effectsCount,
+ })}
+
+ {!analysis.cameraTargetCentered ? (
+
+ {t("trophies:new.specs.stats.cameraTargetOff")}
+
+ ) : null}
+ {!analysis.backgroundIsAlpha ? (
+
+ {t("trophies:new.specs.stats.backgroundNotAlpha")}
+
+ ) : null}
+
+ ) : null}
+ {pending.target ? (
+
+ ) : null}
+ {pending.description ? (
+
{pending.description}
+ ) : null}
+ {pending.approvals.length > 0 && !isDeclined ? (
+
+
+ {t("trophies:new.pending.approvalProgress", {
+ current: pending.approvals.length,
+ required: TROPHY_APPROVALS_REQUIRED,
+ })}
+
+
+ {pending.approvals.some((a) => a.userId === currentUserId)
+ ? `(${pending.approvals.map((a) => a.username).join(", ")})`
+ : ""}
+
+
+ ) : null}
+ {isDeclined ? (
+
+
+ {pending.declinedByUsername
+ ? t("trophies:new.pending.declinedBy", {
+ name: pending.declinedByUsername,
+ })
+ : t("trophies:new.pending.declined")}
+
+
{pending.declineReason}
+
+ ) : null}
+
+ {canReview && !isReviewed ? (
+ <>
+
+ {alreadyApproved
+ ? t("trophies:new.pending.approved")
+ : t("trophies:new.pending.approve")}
+
+
+ }
+ onPress={() =>
+ navigator.clipboard.writeText(
+ decompressTrophyModel(pending.model ?? "{}") ?? "",
+ )
+ }
+ />
+ >
+ ) : null}
+ {isOwner || canReview ? (
+ }
+ />
+ ) : null}
+
+
+
+ );
+}
+
+function DeclineButton({ pendingTrophyId }: { pendingTrophyId: number }) {
+ const { t } = useTranslation(["trophies"]);
+ const [isOpen, setIsOpen] = React.useState(false);
+ const [reason, setReason] = React.useState("");
+ const fetcher = useFetcher();
+ const id = React.useId();
+
+ React.useEffect(() => {
+ if (fetcher.state === "idle" && fetcher.data === null && isOpen) {
+ setIsOpen(false);
+ setReason("");
+ }
+ }, [fetcher.state, fetcher.data, isOpen]);
+
+ return (
+ <>
+ setIsOpen(true)}
+ >
+ {t("trophies:new.pending.decline")}
+
+ {isOpen ? (
+ setIsOpen(false)}
+ showCloseButton
+ >
+
+
+ ) : null}
+ >
+ );
+}
+
+function PendingTrophyDiff({
+ pending,
+ target,
+}: {
+ pending: NewTrophyLoaderData["pendingTrophies"][number];
+ target: NonNullable;
+}) {
+ const { t } = useTranslation(["trophies", "forms"]);
+
+ const newManagerId = pending.managerId ?? pending.submitterUserId;
+ const newManagerName =
+ pending.manager?.username ?? pending.submitterUsername ?? "?";
+
+ const fields: Array<{
+ label: string;
+ oldValue: React.ReactNode;
+ newValue: React.ReactNode;
+ changed: boolean;
+ }> = [
+ {
+ label: t("forms:labels.trophyName"),
+ oldValue: target.name,
+ newValue: pending.name,
+ changed: target.name !== pending.name,
+ },
+ {
+ label: t("forms:labels.trophyOrganization"),
+ oldValue: target.organizationName ?? "—",
+ newValue: pending.organizationName ?? "—",
+ changed: target.organizationId !== pending.organizationId,
+ },
+ {
+ label: t("forms:labels.trophyManager"),
+ oldValue: target.managerUsername ?? "—",
+ newValue: newManagerName,
+ changed: target.managerId !== newManagerId,
+ },
+ {
+ label: t("forms:labels.trophyModel"),
+ oldValue: "-----",
+ newValue: "-----",
+ changed: target.model !== pending.model,
+ },
+ ];
+
+ const changedFields = fields.filter((field) => field.changed);
+ if (changedFields.length === 0) return null;
+
+ return (
+
+
{t("trophies:new.update.before")}
+
{t("trophies:new.update.after")}
+ {changedFields.map((field) => (
+
+
+ {field.label}
+
+ {field.oldValue}
+
+
+
+ {field.label}
+ {field.newValue}
+
+
+ ))}
+
+ );
+}
diff --git a/app/features/trophies/routes/trophies.tsx b/app/features/trophies/routes/trophies.tsx
new file mode 100644
index 000000000..e4eee0ec6
--- /dev/null
+++ b/app/features/trophies/routes/trophies.tsx
@@ -0,0 +1,112 @@
+import { CalendarClock, Search } from "lucide-react";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ Link,
+ type MetaFunction,
+ NavLink,
+ Outlet,
+ useLoaderData,
+} from "react-router";
+import { Input } from "~/components/Input";
+import { Main } from "~/components/Main";
+import type { SendouRouteHandle } from "~/utils/remix.server";
+import { BADGES_PAGE, navIconUrl, TROPHIES_PAGE } from "~/utils/urls";
+import { metaTags } from "../../../utils/remix";
+import {
+ Trophy,
+ TrophyContextProvider,
+ TrophyGrid,
+ TrophyPlaceholder,
+} from "../components/Trophy";
+import { loader } from "../loaders/trophies.server";
+import {
+ hasUpcomingTournamentSoon,
+ useProgressiveRender,
+} from "../trophies-utils";
+import styles from "./trophies.module.css";
+
+export { loader };
+
+export const handle: SendouRouteHandle = {
+ i18n: "trophies",
+ breadcrumb: () => ({
+ imgPath: navIconUrl("trophies"),
+ href: TROPHIES_PAGE,
+ type: "IMAGE",
+ }),
+};
+
+export const meta: MetaFunction = (args) => {
+ return metaTags({
+ title: "Trophies",
+ ogTitle: "Splatoon trophies for tournaments",
+ location: args.location,
+ description:
+ "A full list of all trophies that can be won in Splatoon tournaments.",
+ });
+};
+
+export default function TrophiesPage() {
+ const { t } = useTranslation(["trophies"]);
+ const data = useLoaderData();
+
+ const [inputValue, setInputValue] = useState("");
+ const inputValueNormalized = inputValue.toLowerCase();
+ const filteredTrophies = data.trophies.filter((trophy) =>
+ trophy.name.toLowerCase().includes(inputValueNormalized),
+ );
+ const visibleCount = useProgressiveRender(
+ filteredTrophies.length,
+ inputValue,
+ );
+
+ return (
+
+
+
+
+ }
+ value={inputValue}
+ onChange={(e) => setInputValue(e.target.value)}
+ />
+
+
+ {filteredTrophies.map((trophy, i) =>
+ i < visibleCount ? (
+
+
+ ) : undefined
+ }
+ />
+
+ ) : (
+
+ ),
+ )}
+
+
+
+
+ {t("trophies:lookingForBadges")}{" "}
+ {t("trophies:viewBadges")}
+
+
+
+ );
+}
diff --git a/app/features/trophies/trophies-constants.ts b/app/features/trophies/trophies-constants.ts
new file mode 100644
index 000000000..19c6fa763
--- /dev/null
+++ b/app/features/trophies/trophies-constants.ts
@@ -0,0 +1,25 @@
+export const TROPHY_NAME_MIN_LENGTH = 1;
+export const TROPHY_NAME_MAX_LENGTH = 32;
+
+export const TROPHY_MODEL_MAX_LENGTH = 1024 * 1024;
+export const TROPHY_MODEL_RECOMMENDED_MAX_DRAW_CALLS = 15;
+export const TROPHY_MODEL_RECOMMENDED_MAX_POLYS = 3000;
+export const TROPHY_MODEL_RECOMMENDED_MAX_EFFECTS = 5;
+
+export const TROPHY_APPROVALS_REQUIRED = 2;
+
+export const TROPHY_DESCRIPTION_MAX_LENGTH = 3000;
+
+export const TROPHY_DECLINE_REASON_MIN_LENGTH = 1;
+export const TROPHY_DECLINE_REASON_MAX_LENGTH = 3000;
+
+export const TROPHY_PENDING_PER_USER_LIMIT = 5;
+
+export const SMALL_TROPHIES_PER_DISPLAY_PAGE = 6;
+
+export const TROPHY_UPCOMING_HIGHLIGHT_WEEKS = 4;
+
+export const TROPHIES_RELEASED = false;
+
+export const SUPPORTER_TROPHY_CODE = "supporter";
+export const XP_TROPHY_CODE_PREFIX = "xp-";
diff --git a/app/features/trophies/trophies-schemas.ts b/app/features/trophies/trophies-schemas.ts
new file mode 100644
index 000000000..c1f10d765
--- /dev/null
+++ b/app/features/trophies/trophies-schemas.ts
@@ -0,0 +1,106 @@
+import { z } from "zod";
+import {
+ customField,
+ stringConstant,
+ textAreaOptional,
+ textField,
+} from "~/form/fields";
+import { _action, id } from "~/utils/zod";
+import { analyzeTrophyModel } from "./core/model-analysis";
+import {
+ TROPHY_DECLINE_REASON_MAX_LENGTH,
+ TROPHY_DECLINE_REASON_MIN_LENGTH,
+ TROPHY_DESCRIPTION_MAX_LENGTH,
+ TROPHY_MODEL_MAX_LENGTH,
+ TROPHY_NAME_MAX_LENGTH,
+ TROPHY_NAME_MIN_LENGTH,
+} from "./trophies-constants";
+
+const trophyModelField = () =>
+ customField(
+ { initialValue: "" },
+ z
+ .string()
+ .trim()
+ .min(1)
+ .max(TROPHY_MODEL_MAX_LENGTH)
+ .superRefine((model, ctx) => {
+ const analysis = analyzeTrophyModel(model);
+
+ if (!analysis) {
+ ctx.addIssue({ code: "custom", message: "Invalid model state" });
+ return;
+ }
+
+ if (!analysis.cameraTargetCentered) {
+ ctx.addIssue({
+ code: "custom",
+ message: "Camera target X and Z must be 0",
+ });
+ }
+
+ if (!analysis.backgroundIsAlpha) {
+ ctx.addIssue({
+ code: "custom",
+ message: "Background color must be the alpha color",
+ });
+ }
+ }),
+ );
+
+export const createTrophyFormSchema = z.object({
+ _action: stringConstant("CREATE"),
+ name: textField({
+ label: "labels.trophyName",
+ minLength: TROPHY_NAME_MIN_LENGTH,
+ maxLength: TROPHY_NAME_MAX_LENGTH,
+ }),
+ model: trophyModelField(),
+ organizationId: customField({ initialValue: null }, id),
+ description: textAreaOptional({
+ label: "labels.trophyInformation",
+ maxLength: TROPHY_DESCRIPTION_MAX_LENGTH,
+ }),
+});
+
+export const updateTrophyFormSchema = z.object({
+ _action: stringConstant("UPDATE"),
+ targetTrophyId: customField({ initialValue: null }, id),
+ name: textField({
+ label: "labels.trophyName",
+ minLength: TROPHY_NAME_MIN_LENGTH,
+ maxLength: TROPHY_NAME_MAX_LENGTH,
+ }),
+ model: trophyModelField(),
+ organizationId: customField({ initialValue: null }, id),
+ managerId: customField({ initialValue: null }, id),
+ description: textAreaOptional({
+ label: "labels.trophyInformation",
+ maxLength: TROPHY_DESCRIPTION_MAX_LENGTH,
+ }),
+});
+
+export const trophyFormSchema = z.discriminatedUnion("_action", [
+ createTrophyFormSchema,
+ updateTrophyFormSchema,
+]);
+
+export const pendingTrophyActionSchema = z.union([
+ z.object({
+ _action: _action("DELETE"),
+ pendingTrophyId: id,
+ }),
+ z.object({
+ _action: _action("DECLINE"),
+ pendingTrophyId: id,
+ reason: z
+ .string()
+ .trim()
+ .min(TROPHY_DECLINE_REASON_MIN_LENGTH)
+ .max(TROPHY_DECLINE_REASON_MAX_LENGTH),
+ }),
+ z.object({
+ _action: _action("APPROVE"),
+ pendingTrophyId: id,
+ }),
+]);
diff --git a/app/features/trophies/trophies-utils.test.ts b/app/features/trophies/trophies-utils.test.ts
new file mode 100644
index 000000000..1ed021723
--- /dev/null
+++ b/app/features/trophies/trophies-utils.test.ts
@@ -0,0 +1,123 @@
+import { addWeeks, subDays } from "date-fns";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { decompressFromBase64 } from "~/utils/compression";
+import { dateToDatabaseTimestamp } from "~/utils/dates";
+import {
+ canAccessTrophies,
+ decompressTrophyModel,
+ hasUpcomingTournamentSoon,
+} from "./trophies-utils";
+
+vi.mock("~/utils/compression", () => ({
+ compressToBase64: vi.fn((value: string) => value),
+ decompressFromBase64: vi.fn((_compressed: string): string | null => null),
+}));
+
+const decompressMock = vi.mocked(decompressFromBase64);
+
+const ENTRY_CHARS = 3_500_000;
+
+describe("decompressTrophyModel", () => {
+ beforeEach(() => {
+ decompressMock.mockReset();
+ });
+
+ test("decompresses each distinct model once", () => {
+ flushCache("once");
+ decompressMock.mockImplementation((key) => `decompressed-${key}`);
+
+ expect(decompressTrophyModel("once-model")).toBe("decompressed-once-model");
+ expect(decompressTrophyModel("once-model")).toBe("decompressed-once-model");
+ expect(callsFor("once-model")).toBe(1);
+ });
+
+ test("caches null results of corrupt models", () => {
+ flushCache("corrupt");
+ decompressMock.mockImplementation(() => null);
+
+ expect(decompressTrophyModel("corrupt-model")).toBe(null);
+ expect(decompressTrophyModel("corrupt-model")).toBe(null);
+ expect(callsFor("corrupt-model")).toBe(1);
+ });
+
+ test("evicts the least recently used entry when over the character budget", () => {
+ flushCache("lru");
+ decompressMock.mockImplementation(() => "x".repeat(ENTRY_CHARS));
+
+ decompressTrophyModel("lru-a");
+ decompressTrophyModel("lru-b");
+ decompressTrophyModel("lru-c");
+ decompressTrophyModel("lru-d");
+
+ decompressTrophyModel("lru-a");
+ expect(callsFor("lru-a")).toBe(1);
+
+ decompressTrophyModel("lru-e");
+
+ expect(decompressTrophyModel("lru-a")?.length).toBe(ENTRY_CHARS);
+ expect(callsFor("lru-a")).toBe(1);
+
+ decompressTrophyModel("lru-b");
+ expect(callsFor("lru-b")).toBe(2);
+ });
+});
+
+describe("canAccessTrophies (before release)", () => {
+ test("true for admin", () => {
+ expect(canAccessTrophies({ roles: ["ADMIN"] })).toBe(true);
+ });
+
+ test("true for QA", () => {
+ expect(canAccessTrophies({ roles: ["QA"] })).toBe(true);
+ });
+
+ test("false for staff", () => {
+ expect(canAccessTrophies({ roles: ["STAFF"] })).toBe(false);
+ });
+
+ test("false for regular and logged out users", () => {
+ expect(canAccessTrophies({ roles: [] })).toBe(false);
+ expect(canAccessTrophies(null)).toBe(false);
+ });
+});
+
+describe("hasUpcomingTournamentSoon", () => {
+ test("false for a trophy without an upcoming tournament", () => {
+ expect(hasUpcomingTournamentSoon(null)).toBe(false);
+ });
+
+ test("true for a start time within the window", () => {
+ expect(
+ hasUpcomingTournamentSoon(
+ dateToDatabaseTimestamp(addWeeks(new Date(), 2)),
+ ),
+ ).toBe(true);
+ });
+
+ test("false for a start time in the past", () => {
+ expect(
+ hasUpcomingTournamentSoon(
+ dateToDatabaseTimestamp(subDays(new Date(), 1)),
+ ),
+ ).toBe(false);
+ });
+
+ test("false for a start time beyond the window", () => {
+ expect(
+ hasUpcomingTournamentSoon(
+ dateToDatabaseTimestamp(addWeeks(new Date(), 5)),
+ ),
+ ).toBe(false);
+ });
+});
+
+function callsFor(key: string) {
+ return decompressMock.mock.calls.filter(([arg]) => arg === key).length;
+}
+
+function flushCache(prefix: string) {
+ decompressMock.mockImplementation(() => "x".repeat(17 * 1024 * 1024));
+ decompressTrophyModel(`${prefix}-flush-filler`);
+ decompressMock.mockImplementation(() => "");
+ decompressTrophyModel(`${prefix}-flush-remainder`);
+}
diff --git a/app/features/trophies/trophies-utils.ts b/app/features/trophies/trophies-utils.ts
new file mode 100644
index 000000000..083b537c9
--- /dev/null
+++ b/app/features/trophies/trophies-utils.ts
@@ -0,0 +1,166 @@
+import { addWeeks } from "date-fns";
+import { useEffect, useRef, useState, useSyncExternalStore } from "react";
+import type { Role } from "~/modules/permissions/types";
+import { compressToBase64, decompressFromBase64 } from "~/utils/compression";
+import { databaseTimestampToDate } from "~/utils/dates";
+import {
+ SUPPORTER_TROPHY_CODE,
+ TROPHIES_RELEASED,
+ TROPHY_UPCOMING_HIGHLIGHT_WEEKS,
+ XP_TROPHY_CODE_PREFIX,
+} from "./trophies-constants";
+
+const TERMS_AGREED_SESSION_STORAGE_KEY = "trophyTermsAgreed";
+
+const DECOMPRESSED_MODEL_CACHE_MAX_CHARS = 16 * 1024 * 1024;
+
+type SpecialTrophyKind = { type: "supporter" } | { type: "xp"; value: number };
+
+export function parseSpecialTrophyCode(
+ code: string | null | undefined,
+): SpecialTrophyKind | null {
+ if (!code) return null;
+ if (code === SUPPORTER_TROPHY_CODE) return { type: "supporter" };
+
+ if (code.startsWith(XP_TROPHY_CODE_PREFIX)) {
+ const value = Number(code.slice(XP_TROPHY_CODE_PREFIX.length));
+ if (Number.isFinite(value)) return { type: "xp", value };
+ }
+
+ return null;
+}
+
+// Feature flag
+export function canAccessTrophies(user?: { roles: Array } | null) {
+ if (TROPHIES_RELEASED) return true;
+ if (!user) return false;
+
+ return user.roles.includes("ADMIN") || user.roles.includes("QA");
+}
+
+export function canReviewTrophies(user?: { roles: Array } | null) {
+ if (!user) return false;
+
+ return user.roles.includes("STAFF") || user.roles.includes("QA");
+}
+
+export function canEditAnyTrophy(user?: { roles: Array } | null) {
+ if (!user) return false;
+
+ return user.roles.includes("ADMIN");
+}
+
+export function canEditTrophy(
+ user: { id: number; roles: Array } | null | undefined,
+ trophy: { managerId: number | null },
+) {
+ if (!user) return false;
+ if (canEditAnyTrophy(user)) return true;
+ return trophy.managerId === user.id;
+}
+
+export function hasUpcomingTournamentSoon(
+ upcomingTournamentAt: number | null | undefined,
+) {
+ if (!upcomingTournamentAt) return false;
+
+ const startTime = databaseTimestampToDate(upcomingTournamentAt);
+ const now = new Date();
+
+ return (
+ startTime > now &&
+ startTime <= addWeeks(now, TROPHY_UPCOMING_HIGHLIGHT_WEEKS)
+ );
+}
+
+export function compressTrophyModel(model: string) {
+ return compressToBase64(model);
+}
+
+const decompressedModelCache = new Map();
+let decompressedModelCacheChars = 0;
+
+export function decompressTrophyModel(modelBase64: string) {
+ const cached = decompressedModelCache.get(modelBase64);
+ if (cached !== undefined) {
+ decompressedModelCache.delete(modelBase64);
+ decompressedModelCache.set(modelBase64, cached);
+ return cached;
+ }
+
+ const decompressed = decompressFromBase64(modelBase64);
+ decompressedModelCache.set(modelBase64, decompressed);
+ decompressedModelCacheChars += cacheEntryChars(modelBase64, decompressed);
+
+ for (const [oldestKey, oldestValue] of decompressedModelCache) {
+ if (
+ decompressedModelCacheChars <= DECOMPRESSED_MODEL_CACHE_MAX_CHARS ||
+ decompressedModelCache.size === 1
+ ) {
+ break;
+ }
+
+ decompressedModelCache.delete(oldestKey);
+ decompressedModelCacheChars -= cacheEntryChars(oldestKey, oldestValue);
+ }
+
+ return decompressed;
+}
+
+export function useTrophyTermsAgreement() {
+ const hasAgreedToTerms = useSyncExternalStore(
+ subscribeToTermsAgreed,
+ getTermsAgreedSnapshot,
+ getTermsAgreedServerSnapshot,
+ );
+
+ const agreeToTerms = () => {
+ sessionStorage.setItem(TERMS_AGREED_SESSION_STORAGE_KEY, "true");
+ for (const listener of termsAgreedListeners) {
+ listener();
+ }
+ };
+
+ return { hasAgreedToTerms, agreeToTerms };
+}
+
+const termsAgreedListeners = new Set<() => void>();
+
+function subscribeToTermsAgreed(listener: () => void) {
+ termsAgreedListeners.add(listener);
+ return () => termsAgreedListeners.delete(listener);
+}
+
+function getTermsAgreedSnapshot() {
+ return sessionStorage.getItem(TERMS_AGREED_SESSION_STORAGE_KEY) === "true";
+}
+
+function getTermsAgreedServerSnapshot() {
+ return false;
+}
+
+function cacheEntryChars(key: string, value: string | null) {
+ return key.length + (value?.length ?? 0);
+}
+
+export function useProgressiveRender(total: number, resetKey: string) {
+ const [count, setCount] = useState(1);
+ const prevKeyRef = useRef(resetKey);
+
+ if (prevKeyRef.current !== resetKey) {
+ prevKeyRef.current = resetKey;
+ setCount(1);
+ }
+
+ useEffect(() => {
+ if (count >= total) return;
+
+ const id = requestAnimationFrame(() => {
+ setCount((c) => c + 1);
+ });
+
+ return () => cancelAnimationFrame(id);
+ }, [count, total]);
+
+ return count;
+}
diff --git a/app/features/user-page/UserRepository.server.ts b/app/features/user-page/UserRepository.server.ts
index 427950348..40fb190a0 100644
--- a/app/features/user-page/UserRepository.server.ts
+++ b/app/features/user-page/UserRepository.server.ts
@@ -156,6 +156,8 @@ export async function findProfileByIdentifier(
"User.showDiscordUniqueName",
"User.discordUniqueName",
"User.favoriteBadgeIds",
+ "User.favoriteTrophyIds",
+ "User.hiddenTrophyIds",
"User.patronTier",
"PlusTier.tier as plusTier",
"User.pronouns",
@@ -469,6 +471,18 @@ export function findAllPlusServerMembers() {
.execute();
}
+export async function existingUserIds(userIds: Array) {
+ if (userIds.length === 0) return [];
+
+ const rows = await db
+ .selectFrom("User")
+ .select("User.id")
+ .where("User.id", "in", userIds)
+ .execute();
+
+ return rows.map((row) => row.id);
+}
+
export async function findChatUsersByUserIds(userIds: number[]) {
const users = await db
.selectFrom("User")
@@ -1050,6 +1064,8 @@ type UpdateProfileArgs = Pick<
> & {
weapons: Pick[];
favoriteBadgeIds?: number[] | null;
+ favoriteTrophyIds?: number[] | null;
+ hiddenTrophyIds?: number[] | null;
customAvatarImgId?: number | null;
};
export function updateOwnProfile(args: UpdateProfileArgs) {
@@ -1104,6 +1120,12 @@ export function updateOwnProfile(args: UpdateProfileArgs) {
favoriteBadgeIds: args.favoriteBadgeIds
? JSON.stringify(args.favoriteBadgeIds)
: null,
+ favoriteTrophyIds: args.favoriteTrophyIds
+ ? JSON.stringify(args.favoriteTrophyIds)
+ : null,
+ hiddenTrophyIds: args.hiddenTrophyIds
+ ? JSON.stringify(args.hiddenTrophyIds)
+ : null,
showDiscordUniqueName: args.showDiscordUniqueName,
commissionText: args.commissionText,
commissionsOpen: args.commissionsOpen,
diff --git a/app/features/user-page/actions/u.$identifier.edit.server.ts b/app/features/user-page/actions/u.$identifier.edit.server.ts
index 16a963150..0848cb352 100644
--- a/app/features/user-page/actions/u.$identifier.edit.server.ts
+++ b/app/features/user-page/actions/u.$identifier.edit.server.ts
@@ -3,6 +3,7 @@ import { requireUser } from "~/features/auth/core/user.server";
import { BADGE } from "~/features/badges/badges-constants";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import { clearTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server";
+import { SMALL_TROPHIES_PER_DISPLAY_PAGE } from "~/features/trophies/trophies-constants";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { parseFormDataWithImages } from "~/form/parse.server";
import { userPage } from "~/utils/urls";
@@ -54,6 +55,13 @@ export const action: ActionFunction = async ({ request }) => {
: 1;
const limitedBadgeIds = data.favoriteBadgeIds.slice(0, maxBadgeCount);
+ const hiddenTrophySet = new Set(data.hiddenTrophyIds);
+ const limitedTrophyIds = isSupporter
+ ? data.favoriteTrophyIds
+ .filter((id) => !hiddenTrophySet.has(id))
+ .slice(0, SMALL_TROPHIES_PER_DISPLAY_PAGE)
+ : [];
+
const editedUser = await UserRepository.updateOwnProfile({
country: data.country,
bio: data.bio,
@@ -66,6 +74,9 @@ export const action: ActionFunction = async ({ request }) => {
battlefy: data.battlefy,
weapons,
favoriteBadgeIds: limitedBadgeIds.length > 0 ? limitedBadgeIds : null,
+ favoriteTrophyIds: limitedTrophyIds.length > 0 ? limitedTrophyIds : null,
+ hiddenTrophyIds:
+ data.hiddenTrophyIds.length > 0 ? data.hiddenTrophyIds : null,
showDiscordUniqueName: data.showDiscordUniqueName ? 1 : 0,
commissionsOpen: isArtist && data.commissionsOpen ? 1 : 0,
commissionText: isArtist ? data.commissionText : null,
diff --git a/app/features/user-page/components/ParticipationPill.module.css b/app/features/user-page/components/ParticipationPill.module.css
index dfdb8fbb1..9b1f2443b 100644
--- a/app/features/user-page/components/ParticipationPill.module.css
+++ b/app/features/user-page/components/ParticipationPill.module.css
@@ -25,5 +25,5 @@
}
.participating {
- background-color: var(--color-accent);
+ background-color: var(--color-text-accent);
}
diff --git a/app/features/user-page/components/Widget.tsx b/app/features/user-page/components/Widget.tsx
index d27c50ac7..b98280257 100644
--- a/app/features/user-page/components/Widget.tsx
+++ b/app/features/user-page/components/Widget.tsx
@@ -18,6 +18,7 @@ import { Placement } from "~/components/Placement";
import type { Tables } from "~/db/tables";
import { previewUrl } from "~/features/art/art-utils";
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
+import { TrophyDisplay } from "~/features/trophies/components/TrophyDisplay";
import { VodListing } from "~/features/vods/components/VodListing";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useAutoRerender } from "~/hooks/useAutoRerender";
@@ -78,6 +79,8 @@ export function Widget({
{widget.data.bio}
);
+ case "trophies-owned":
+ return ;
case "badges-owned":
return (
diff --git a/app/features/user-page/core/trophy-sorting.server.ts b/app/features/user-page/core/trophy-sorting.server.ts
new file mode 100644
index 000000000..cf1624202
--- /dev/null
+++ b/app/features/user-page/core/trophy-sorting.server.ts
@@ -0,0 +1,59 @@
+import { isSupporter } from "~/modules/permissions/utils";
+
+interface SortTrophiesByFavoritesArgs<
+ T extends Array<{ id: number; tier?: number | null }>,
+> {
+ favoriteTrophyIds: number[] | null;
+ hiddenTrophyIds: number[] | null;
+ trophies: T;
+ patronTier: number | null;
+}
+
+export function sortTrophiesByFavorites<
+ T extends Array<{ id: number; tier?: number | null }>,
+>({
+ favoriteTrophyIds,
+ hiddenTrophyIds,
+ trophies,
+ patronTier,
+}: SortTrophiesByFavoritesArgs): {
+ trophies: T;
+ favoriteTrophyIds: number[] | null;
+} {
+ const hiddenSet = new Set(hiddenTrophyIds ?? []);
+
+ let filteredFavoriteIds =
+ favoriteTrophyIds?.filter(
+ (trophyId) =>
+ !hiddenSet.has(trophyId) &&
+ trophies.some((trophy) => trophy.id === trophyId),
+ ) ?? null;
+
+ if (filteredFavoriteIds?.length === 0) {
+ filteredFavoriteIds = null;
+ }
+
+ filteredFavoriteIds = isSupporter({ patronTier })
+ ? filteredFavoriteIds
+ : null;
+
+ const sortedTrophies = trophies.toSorted((a, b) => {
+ const aIdx = filteredFavoriteIds?.indexOf(a.id) ?? -1;
+ const bIdx = filteredFavoriteIds?.indexOf(b.id) ?? -1;
+
+ if (aIdx !== bIdx) {
+ if (aIdx === -1) return 1;
+ if (bIdx === -1) return -1;
+
+ return aIdx - bIdx;
+ }
+
+ const aTier = a.tier ?? Number.MAX_SAFE_INTEGER;
+ const bTier = b.tier ?? Number.MAX_SAFE_INTEGER;
+ if (aTier !== bTier) return aTier - bTier;
+
+ return b.id - a.id;
+ }) as T;
+
+ return { trophies: sortedTrophies, favoriteTrophyIds: filteredFavoriteIds };
+}
diff --git a/app/features/user-page/core/widgets/portfolio-loaders.server.ts b/app/features/user-page/core/widgets/portfolio-loaders.server.ts
index b42a13a4f..24e005e4e 100644
--- a/app/features/user-page/core/widgets/portfolio-loaders.server.ts
+++ b/app/features/user-page/core/widgets/portfolio-loaders.server.ts
@@ -1,4 +1,5 @@
import * as ArtRepository from "~/features/art/ArtRepository.server";
+import { getUser } from "~/features/auth/core/user.server";
import * as BadgeRepository from "~/features/badges/BadgeRepository.server";
import * as BuildRepository from "~/features/builds/BuildRepository.server";
import * as FriendRepository from "~/features/friends/FriendRepository.server";
@@ -9,6 +10,8 @@ import { userSkills as _userSkills } from "~/features/mmr/tiered.server";
import * as TeamRepository from "~/features/team/TeamRepository.server";
import * as XRankPlacementRepository from "~/features/top-search/XRankPlacementRepository.server";
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
+import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
+import { canAccessTrophies } from "~/features/trophies/trophies-utils";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import * as VodRepository from "~/features/vods/VodRepository.server";
import { weaponCategories } from "~/modules/in-game-lists/weapon-ids";
@@ -16,6 +19,11 @@ import type { ExtractWidgetSettings } from "./types";
import { cachedUserSQLeaderboardTopData } from "./utils.server";
export const WIDGET_LOADERS = {
+ "trophies-owned": async (userId: number) => {
+ if (!canAccessTrophies(getUser())) return [];
+
+ return TrophyRepository.findByOwnerUserId(userId);
+ },
"badges-owned": async (userId: number) => {
return BadgeRepository.findByOwnerUserId(userId);
},
diff --git a/app/features/user-page/core/widgets/portfolio.ts b/app/features/user-page/core/widgets/portfolio.ts
index aeb658cd8..c42c825b2 100644
--- a/app/features/user-page/core/widgets/portfolio.ts
+++ b/app/features/user-page/core/widgets/portfolio.ts
@@ -73,6 +73,7 @@ export const ALL_WIDGETS = {
defaultSettings: { searchParams: "" },
}),
],
+ trophies: [defineWidget({ id: "trophies-owned", slot: "main" })],
badges: [
defineWidget({ id: "badges-owned", slot: "main" }),
defineWidget({ id: "badges-authored", slot: "main" }),
diff --git a/app/features/user-page/loaders/u.$identifier.edit.server.ts b/app/features/user-page/loaders/u.$identifier.edit.server.ts
index 3b891078a..bc61ff13a 100644
--- a/app/features/user-page/loaders/u.$identifier.edit.server.ts
+++ b/app/features/user-page/loaders/u.$identifier.edit.server.ts
@@ -1,5 +1,7 @@
import { type LoaderFunctionArgs, redirect } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
+import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
+import { canAccessTrophies } from "~/features/trophies/trophies-utils";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { notFoundIfNullish } from "~/utils/remix.server";
import { userPage } from "~/utils/urls";
@@ -23,10 +25,16 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
const friendCodeResult = await UserRepository.findCurrentFriendCodeByUserId(
user.id,
);
+ const ownedTrophies = canAccessTrophies(user)
+ ? await TrophyRepository.findByOwnerUserIdIncludingHidden(user.id)
+ : [];
return {
user: userProfile,
favoriteBadgeIds: userProfile.favoriteBadgeIds,
+ favoriteTrophyIds: userProfile.favoriteTrophyIds,
+ hiddenTrophyIds: userProfile.hiddenTrophyIds,
+ ownedTrophies,
discordUniqueName: userProfile.discordUniqueName,
newProfileEnabled: preferences?.newProfileEnabled ?? false,
friendCode: friendCodeResult?.friendCode ?? null,
diff --git a/app/features/user-page/loaders/u.$identifier.index.server.ts b/app/features/user-page/loaders/u.$identifier.index.server.ts
index 05c445abc..7972af40a 100644
--- a/app/features/user-page/loaders/u.$identifier.index.server.ts
+++ b/app/features/user-page/loaders/u.$identifier.index.server.ts
@@ -1,4 +1,7 @@
import type { LoaderFunctionArgs } from "react-router";
+import { getUser } from "~/features/auth/core/user.server";
+import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
+import { canAccessTrophies } from "~/features/trophies/trophies-utils";
import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { notFoundIfNullish } from "~/utils/remix.server";
@@ -30,9 +33,14 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
await UserRepository.findProfileByIdentifier(params.identifier!),
);
+ const trophies = canAccessTrophies(getUser())
+ ? await TrophyRepository.findByOwnerUserId(user.id)
+ : [];
+
return {
type: "old" as const,
user,
+ trophies,
...userCards,
};
};
diff --git a/app/features/user-page/routes/u.$identifier.edit.test.ts b/app/features/user-page/routes/u.$identifier.edit.test.ts
index c3fc0d3ad..0c2ba3328 100644
--- a/app/features/user-page/routes/u.$identifier.edit.test.ts
+++ b/app/features/user-page/routes/u.$identifier.edit.test.ts
@@ -19,6 +19,8 @@ const DEFAULT_FIELDS = {
customName: null,
customUrl: null,
favoriteBadgeIds: [],
+ favoriteTrophyIds: [],
+ hiddenTrophyIds: [],
inGameName: null,
sensitivity: [null, null] as [null, null],
pronouns: [null, null] as [null, null],
diff --git a/app/features/user-page/routes/u.$identifier.edit.tsx b/app/features/user-page/routes/u.$identifier.edit.tsx
index 66d5c8c07..802f68837 100644
--- a/app/features/user-page/routes/u.$identifier.edit.tsx
+++ b/app/features/user-page/routes/u.$identifier.edit.tsx
@@ -3,6 +3,7 @@ import { Link, useLoaderData, useMatches } from "react-router";
import { FormMessage } from "~/components/FormMessage";
import { FriendCodePopover } from "~/components/FriendCodePopover";
import { BADGE } from "~/features/badges/badges-constants";
+import { SMALL_TROPHIES_PER_DISPLAY_PAGE } from "~/features/trophies/trophies-constants";
import { existingImage } from "~/form/image-field";
import { SendouForm } from "~/form/SendouForm";
import { useHydrated } from "~/hooks/useHydrated";
@@ -41,6 +42,13 @@ export default function UserEditPage() {
hue: badge.hue,
}));
+ const trophyOptions = data.ownedTrophies.map((trophy) => ({
+ id: trophy.id,
+ name: trophy.name,
+ model: trophy.model,
+ tier: trophy.tier,
+ }));
+
const defaultValues = {
customAvatar: existingImage(
data.user.customAvatarImgId,
@@ -54,6 +62,8 @@ export default function UserEditPage() {
battlefy: data.user.battlefy ?? "",
country: data.user.country ?? null,
favoriteBadgeIds: data.favoriteBadgeIds ?? [],
+ favoriteTrophyIds: data.favoriteTrophyIds ?? [],
+ hiddenTrophyIds: data.hiddenTrophyIds ?? [],
weapons: data.user.weapons.map((w) => ({
id: w.weaponSplId,
isFavorite: Boolean(w.isFavorite),
@@ -93,6 +103,16 @@ export default function UserEditPage() {
}
/>
) : null}
+ {isSupporter && data.ownedTrophies.length >= 2 ? (
+
+ ) : null}
+ {data.ownedTrophies.length >= 1 ? (
+
+ ) : null}
{data.discordUniqueName ? (
diff --git a/app/features/user-page/routes/u.$identifier.index.tsx b/app/features/user-page/routes/u.$identifier.index.tsx
index e5150f9c3..05152cc4d 100644
--- a/app/features/user-page/routes/u.$identifier.index.tsx
+++ b/app/features/user-page/routes/u.$identifier.index.tsx
@@ -20,6 +20,7 @@ import { TwitchIcon } from "~/components/icons/Twitch";
import { YouTubeIcon } from "~/components/icons/YouTube";
import { useUser } from "~/features/auth/core/user";
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
+import { TrophyDisplay } from "~/features/trophies/components/TrophyDisplay";
import { UserCard } from "~/features/user-card/components/UserCard";
import { modesShort } from "~/modules/in-game-lists/modes";
import { countryCodeToTranslatedName } from "~/utils/i18n";
@@ -56,6 +57,7 @@ export const handle: SendouRouteHandle = {
"weapons",
"gear",
"game-badges",
+ "trophies",
],
};
@@ -231,7 +233,17 @@ export function OldUserInfoPage() {
-
+ {data.trophies.length > 0 ? (
+
+ ) : null}
+
{data.user.bio && {data.user.bio} }
);
diff --git a/app/features/user-page/user-page-schemas.ts b/app/features/user-page/user-page-schemas.ts
index 89a190d0a..7acad05fd 100644
--- a/app/features/user-page/user-page-schemas.ts
+++ b/app/features/user-page/user-page-schemas.ts
@@ -1,6 +1,7 @@
import { z } from "zod";
import { BADGE } from "~/features/badges/badges-constants";
import * as Seasons from "~/features/mmr/core/Seasons";
+import { SMALL_TROPHIES_PER_DISPLAY_PAGE } from "~/features/trophies/trophies-constants";
import {
OBJECT_PRONOUNS,
SUBJECT_PRONOUNS,
@@ -20,6 +21,7 @@ import {
textField,
textFieldOptional,
toggle,
+ trophies,
weaponPool,
} from "~/form/fields";
import {
@@ -142,6 +144,13 @@ export const userEditProfileBaseSchema = z.object({
label: "labels.profileFavoriteBadges",
maxCount: BADGE.SMALL_BADGES_PER_DISPLAY_PAGE + 1,
}),
+ favoriteTrophyIds: trophies({
+ label: "labels.profileFavoriteTrophies",
+ maxCount: SMALL_TROPHIES_PER_DISPLAY_PAGE,
+ }),
+ hiddenTrophyIds: trophies({
+ label: "labels.profileHiddenTrophies",
+ }),
weapons: weaponPool({
label: "labels.weaponPool",
maxCount: USER.WEAPON_POOL_MAX_SIZE,
diff --git a/app/form/FormField.tsx b/app/form/FormField.tsx
index 16c54cbef..6f00c37ce 100644
--- a/app/form/FormField.tsx
+++ b/app/form/FormField.tsx
@@ -21,6 +21,7 @@ import { TeamSearchFormField } from "./fields/TeamSearchFormField";
import { TextareaFormField } from "./fields/TextareaFormField";
import { TimeRangeFormField } from "./fields/TimeRangeFormField";
import { TournamentSearchFormField } from "./fields/TournamentSearchFormField";
+import { TrophiesFormField } from "./fields/TrophiesFormField";
import { UserSearchFormField } from "./fields/UserSearchFormField";
import {
WeaponPoolFormField,
@@ -38,6 +39,7 @@ import type {
SelectOption,
TeamSearchFieldOptions,
TournamentSearchFieldOptions,
+ TrophyOption,
UserSearchFieldOptions,
} from "./types";
import {
@@ -557,6 +559,22 @@ export function FormField({
);
}
+ if (formField.type === "trophies") {
+ if (!options) {
+ throw new Error("Trophies form field requires options prop");
+ }
+ return (
+ void}
+ options={options as TrophyOption[]}
+ {...(maxCount !== undefined ? { maxCount } : {})}
+ />
+ );
+ }
+
if (formField.type === "stage-select") {
return (
();
@@ -838,6 +839,23 @@ export function badges(
}) as z.ZodArray & FieldWithOptions;
}
+export function trophies(
+ args: WithTypedTranslationKeys<
+ Omit, "type" | "initialValue">
+ >,
+) {
+ return z
+ .array(id)
+ .max(args.maxCount ?? 100)
+ .register(formRegistry, {
+ ...args,
+ label: prefixKey(args.label),
+ bottomText: prefixKey(args.bottomText),
+ type: "trophies",
+ initialValue: [],
+ }) as z.ZodArray & FieldWithOptions;
+}
+
export function stageSelect(
args: WithTypedTranslationKeys<
Omit<
diff --git a/app/form/fields/TrophiesFormField.tsx b/app/form/fields/TrophiesFormField.tsx
new file mode 100644
index 000000000..dda2cf5e8
--- /dev/null
+++ b/app/form/fields/TrophiesFormField.tsx
@@ -0,0 +1,43 @@
+import * as React from "react";
+import { TrophiesSelector } from "~/features/trophies/components/TrophiesSelector";
+import type { FormFieldProps, TrophyOption } from "../types";
+import { FormFieldWrapper } from "./FormFieldWrapper";
+
+type TrophiesFormFieldProps = Omit, "onBlur"> & {
+ value: number[];
+ onChange: (value: number[]) => void;
+ onBlur?: () => void;
+ options: TrophyOption[];
+};
+
+export function TrophiesFormField({
+ name,
+ label,
+ bottomText,
+ error,
+ maxCount,
+ value,
+ onChange,
+ onBlur,
+ options,
+}: TrophiesFormFieldProps) {
+ const id = React.useId();
+
+ return (
+
+
+
+ );
+}
diff --git a/app/form/types.ts b/app/form/types.ts
index ca2f1296d..2f7d2acd7 100644
--- a/app/form/types.ts
+++ b/app/form/types.ts
@@ -163,6 +163,10 @@ interface FormFieldBadges extends FormFieldBase {
maxCount?: number;
}
+interface FormFieldTrophies extends FormFieldBase {
+ maxCount?: number;
+}
+
interface FormFieldSelectDynamic extends FormFieldBase {
clearable: boolean;
searchable?: boolean;
@@ -205,6 +209,7 @@ export type FormField =
| FormFieldTournamentSearch<"tournament-search">
| FormFieldTeamSearch<"team-search">
| FormFieldBadges<"badges">
+ | FormFieldTrophies<"trophies">
| FormFieldStageSelect<"stage-select">
| FormFieldWeaponSelect<"weapon-select">;
@@ -237,6 +242,13 @@ export type BadgeOption = {
hue: number | null;
};
+export type TrophyOption = {
+ id: number;
+ name: string;
+ model: string;
+ tier: number | null;
+};
+
export type SelectOption = {
value: string;
label: string;
diff --git a/app/modules/i18n/resources.server.ts b/app/modules/i18n/resources.server.ts
index f85081659..faf9546f5 100644
--- a/app/modules/i18n/resources.server.ts
+++ b/app/modules/i18n/resources.server.ts
@@ -21,6 +21,7 @@ import settingsDa from "../../../locales/da/settings.json";
import teamDa from "../../../locales/da/team.json";
import tierListMakerDa from "../../../locales/da/tier-list-maker.json";
import tournamentDa from "../../../locales/da/tournament.json";
+import trophiesDa from "../../../locales/da/trophies.json";
import userDa from "../../../locales/da/user.json";
import vodsDa from "../../../locales/da/vods.json";
import weaponsDa from "../../../locales/da/weapons.json";
@@ -48,6 +49,7 @@ import settingsDe from "../../../locales/de/settings.json";
import teamDe from "../../../locales/de/team.json";
import tierListMakerDe from "../../../locales/de/tier-list-maker.json";
import tournamentDe from "../../../locales/de/tournament.json";
+import trophiesDe from "../../../locales/de/trophies.json";
import userDe from "../../../locales/de/user.json";
import vodsDe from "../../../locales/de/vods.json";
import weaponsDe from "../../../locales/de/weapons.json";
@@ -75,6 +77,7 @@ import settings from "../../../locales/en/settings.json";
import team from "../../../locales/en/team.json";
import tierListMaker from "../../../locales/en/tier-list-maker.json";
import tournament from "../../../locales/en/tournament.json";
+import trophies from "../../../locales/en/trophies.json";
import user from "../../../locales/en/user.json";
import vods from "../../../locales/en/vods.json";
import weapons from "../../../locales/en/weapons.json";
@@ -102,6 +105,7 @@ import settingsEsEs from "../../../locales/es-ES/settings.json";
import teamEsEs from "../../../locales/es-ES/team.json";
import tierListMakerEsEs from "../../../locales/es-ES/tier-list-maker.json";
import tournamentEsEs from "../../../locales/es-ES/tournament.json";
+import trophiesEsEs from "../../../locales/es-ES/trophies.json";
import userEsEs from "../../../locales/es-ES/user.json";
import vodsEsEs from "../../../locales/es-ES/vods.json";
import weaponsEsEs from "../../../locales/es-ES/weapons.json";
@@ -129,6 +133,7 @@ import settingsEsUs from "../../../locales/es-US/settings.json";
import teamEsUs from "../../../locales/es-US/team.json";
import tierListMakerEsUs from "../../../locales/es-US/tier-list-maker.json";
import tournamentEsUs from "../../../locales/es-US/tournament.json";
+import trophiesEsUs from "../../../locales/es-US/trophies.json";
import userEsUs from "../../../locales/es-US/user.json";
import vodsEsUs from "../../../locales/es-US/vods.json";
import weaponsEsUs from "../../../locales/es-US/weapons.json";
@@ -156,6 +161,7 @@ import settingsFrCa from "../../../locales/fr-CA/settings.json";
import teamFrCa from "../../../locales/fr-CA/team.json";
import tierListMakerFrCa from "../../../locales/fr-CA/tier-list-maker.json";
import tournamentFrCa from "../../../locales/fr-CA/tournament.json";
+import trophiesFrCa from "../../../locales/fr-CA/trophies.json";
import userFrCa from "../../../locales/fr-CA/user.json";
import vodsFrCa from "../../../locales/fr-CA/vods.json";
import weaponsFrCa from "../../../locales/fr-CA/weapons.json";
@@ -183,6 +189,7 @@ import settingsFrEu from "../../../locales/fr-EU/settings.json";
import teamFrEu from "../../../locales/fr-EU/team.json";
import tierListMakerFrEu from "../../../locales/fr-EU/tier-list-maker.json";
import tournamentFrEu from "../../../locales/fr-EU/tournament.json";
+import trophiesFrEu from "../../../locales/fr-EU/trophies.json";
import userFrEu from "../../../locales/fr-EU/user.json";
import vodsFrEu from "../../../locales/fr-EU/vods.json";
import weaponsFrEu from "../../../locales/fr-EU/weapons.json";
@@ -210,6 +217,7 @@ import settingsHe from "../../../locales/he/settings.json";
import teamHe from "../../../locales/he/team.json";
import tierListMakerHe from "../../../locales/he/tier-list-maker.json";
import tournamentHe from "../../../locales/he/tournament.json";
+import trophiesHe from "../../../locales/he/trophies.json";
import userHe from "../../../locales/he/user.json";
import vodsHe from "../../../locales/he/vods.json";
import weaponsHe from "../../../locales/he/weapons.json";
@@ -237,6 +245,7 @@ import settingsIt from "../../../locales/it/settings.json";
import teamIt from "../../../locales/it/team.json";
import tierListMakerIt from "../../../locales/it/tier-list-maker.json";
import tournamentIt from "../../../locales/it/tournament.json";
+import trophiesIt from "../../../locales/it/trophies.json";
import userIt from "../../../locales/it/user.json";
import vodsIt from "../../../locales/it/vods.json";
import weaponsIt from "../../../locales/it/weapons.json";
@@ -264,6 +273,7 @@ import settingsJa from "../../../locales/ja/settings.json";
import teamJa from "../../../locales/ja/team.json";
import tierListMakerJa from "../../../locales/ja/tier-list-maker.json";
import tournamentJa from "../../../locales/ja/tournament.json";
+import trophiesJa from "../../../locales/ja/trophies.json";
import userJa from "../../../locales/ja/user.json";
import vodsJa from "../../../locales/ja/vods.json";
import weaponsJa from "../../../locales/ja/weapons.json";
@@ -291,6 +301,7 @@ import settingsKo from "../../../locales/ko/settings.json";
import teamKo from "../../../locales/ko/team.json";
import tierListMakerKo from "../../../locales/ko/tier-list-maker.json";
import tournamentKo from "../../../locales/ko/tournament.json";
+import trophiesKo from "../../../locales/ko/trophies.json";
import userKo from "../../../locales/ko/user.json";
import vodsKo from "../../../locales/ko/vods.json";
import weaponsKo from "../../../locales/ko/weapons.json";
@@ -318,6 +329,7 @@ import settingsNl from "../../../locales/nl/settings.json";
import teamNl from "../../../locales/nl/team.json";
import tierListMakerNl from "../../../locales/nl/tier-list-maker.json";
import tournamentNl from "../../../locales/nl/tournament.json";
+import trophiesNl from "../../../locales/nl/trophies.json";
import userNl from "../../../locales/nl/user.json";
import vodsNl from "../../../locales/nl/vods.json";
import weaponsNl from "../../../locales/nl/weapons.json";
@@ -345,6 +357,7 @@ import settingsPl from "../../../locales/pl/settings.json";
import teamPl from "../../../locales/pl/team.json";
import tierListMakerPl from "../../../locales/pl/tier-list-maker.json";
import tournamentPl from "../../../locales/pl/tournament.json";
+import trophiesPl from "../../../locales/pl/trophies.json";
import userPl from "../../../locales/pl/user.json";
import vodsPl from "../../../locales/pl/vods.json";
import weaponsPl from "../../../locales/pl/weapons.json";
@@ -372,6 +385,7 @@ import settingsPtBr from "../../../locales/pt-BR/settings.json";
import teamPtBr from "../../../locales/pt-BR/team.json";
import tierListMakerPtBr from "../../../locales/pt-BR/tier-list-maker.json";
import tournamentPtBr from "../../../locales/pt-BR/tournament.json";
+import trophiesPtBr from "../../../locales/pt-BR/trophies.json";
import userPtBr from "../../../locales/pt-BR/user.json";
import vodsPtBr from "../../../locales/pt-BR/vods.json";
import weaponsPtBr from "../../../locales/pt-BR/weapons.json";
@@ -399,6 +413,7 @@ import settingsRu from "../../../locales/ru/settings.json";
import teamRu from "../../../locales/ru/team.json";
import tierListMakerRu from "../../../locales/ru/tier-list-maker.json";
import tournamentRu from "../../../locales/ru/tournament.json";
+import trophiesRu from "../../../locales/ru/trophies.json";
import userRu from "../../../locales/ru/user.json";
import vodsRu from "../../../locales/ru/vods.json";
import weaponsRu from "../../../locales/ru/weapons.json";
@@ -426,6 +441,7 @@ import settingsZh from "../../../locales/zh/settings.json";
import teamZh from "../../../locales/zh/team.json";
import tierListMakerZh from "../../../locales/zh/tier-list-maker.json";
import tournamentZh from "../../../locales/zh/tournament.json";
+import trophiesZh from "../../../locales/zh/trophies.json";
import userZh from "../../../locales/zh/user.json";
import vodsZh from "../../../locales/zh/vods.json";
import weaponsZh from "../../../locales/zh/weapons.json";
@@ -457,6 +473,7 @@ export const resources = {
badges: badgesEsUs,
contributions: contributionsEsUs,
team: teamEsUs,
+ trophies: trophiesEsUs,
"tier-list-maker": tierListMakerEsUs,
analyzer: analyzerEsUs,
welcome: welcomeEsUs,
@@ -486,6 +503,7 @@ export const resources = {
badges: badges,
contributions: contributions,
team: team,
+ trophies: trophies,
"tier-list-maker": tierListMaker,
analyzer: analyzer,
welcome: welcomeEn,
@@ -515,6 +533,7 @@ export const resources = {
badges: badgesKo,
contributions: contributionsKo,
team: teamKo,
+ trophies: trophiesKo,
"tier-list-maker": tierListMakerKo,
analyzer: analyzerKo,
welcome: welcomeKo,
@@ -544,6 +563,7 @@ export const resources = {
badges: badgesDe,
contributions: contributionsDe,
team: teamDe,
+ trophies: trophiesDe,
"tier-list-maker": tierListMakerDe,
analyzer: analyzerDe,
welcome: welcomeDe,
@@ -573,6 +593,7 @@ export const resources = {
badges: badgesNl,
contributions: contributionsNl,
team: teamNl,
+ trophies: trophiesNl,
"tier-list-maker": tierListMakerNl,
analyzer: analyzerNl,
welcome: welcomeNl,
@@ -602,6 +623,7 @@ export const resources = {
badges: badgesPtBr,
contributions: contributionsPtBr,
team: teamPtBr,
+ trophies: trophiesPtBr,
"tier-list-maker": tierListMakerPtBr,
analyzer: analyzerPtBr,
welcome: welcomePtBr,
@@ -631,6 +653,7 @@ export const resources = {
badges: badgesZh,
contributions: contributionsZh,
team: teamZh,
+ trophies: trophiesZh,
"tier-list-maker": tierListMakerZh,
analyzer: analyzerZh,
welcome: welcomeZh,
@@ -660,6 +683,7 @@ export const resources = {
badges: badgesFrCa,
contributions: contributionsFrCa,
team: teamFrCa,
+ trophies: trophiesFrCa,
"tier-list-maker": tierListMakerFrCa,
analyzer: analyzerFrCa,
welcome: welcomeFrCa,
@@ -689,6 +713,7 @@ export const resources = {
badges: badgesRu,
contributions: contributionsRu,
team: teamRu,
+ trophies: trophiesRu,
"tier-list-maker": tierListMakerRu,
analyzer: analyzerRu,
welcome: welcomeRu,
@@ -718,6 +743,7 @@ export const resources = {
badges: badgesIt,
contributions: contributionsIt,
team: teamIt,
+ trophies: trophiesIt,
"tier-list-maker": tierListMakerIt,
analyzer: analyzerIt,
welcome: welcomeIt,
@@ -747,6 +773,7 @@ export const resources = {
badges: badgesJa,
contributions: contributionsJa,
team: teamJa,
+ trophies: trophiesJa,
"tier-list-maker": tierListMakerJa,
analyzer: analyzerJa,
welcome: welcomeJa,
@@ -776,6 +803,7 @@ export const resources = {
badges: badgesDa,
contributions: contributionsDa,
team: teamDa,
+ trophies: trophiesDa,
"tier-list-maker": tierListMakerDa,
analyzer: analyzerDa,
welcome: welcomeDa,
@@ -805,6 +833,7 @@ export const resources = {
badges: badgesEsEs,
contributions: contributionsEsEs,
team: teamEsEs,
+ trophies: trophiesEsEs,
"tier-list-maker": tierListMakerEsEs,
analyzer: analyzerEsEs,
welcome: welcomeEsEs,
@@ -834,6 +863,7 @@ export const resources = {
badges: badgesHe,
contributions: contributionsHe,
team: teamHe,
+ trophies: trophiesHe,
"tier-list-maker": tierListMakerHe,
analyzer: analyzerHe,
welcome: welcomeHe,
@@ -863,6 +893,7 @@ export const resources = {
badges: badgesFrEu,
contributions: contributionsFrEu,
team: teamFrEu,
+ trophies: trophiesFrEu,
"tier-list-maker": tierListMakerFrEu,
analyzer: analyzerFrEu,
welcome: welcomeFrEu,
@@ -892,6 +923,7 @@ export const resources = {
badges: badgesPl,
contributions: contributionsPl,
team: teamPl,
+ trophies: trophiesPl,
"tier-list-maker": tierListMakerPl,
analyzer: analyzerPl,
welcome: welcomePl,
diff --git a/app/modules/patreon/updater.ts b/app/modules/patreon/updater.ts
index 203f9f6b9..623003b6f 100644
--- a/app/modules/patreon/updater.ts
+++ b/app/modules/patreon/updater.ts
@@ -1,6 +1,7 @@
import type { z } from "zod";
import { ServerConfig } from "~/config.server";
import { STAFF_DISCORD_IDS } from "~/features/admin/admin-constants";
+import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { fetchWithTimeout } from "~/utils/fetch";
@@ -46,6 +47,7 @@ export async function updatePatreonData(): Promise {
];
await UserRepository.updatePatronData(patronsWithMods);
+ await TrophyRepository.syncSpecialTrophies();
}
const MAX_RETRIES = 10;
diff --git a/app/modules/permissions/mapper.server.ts b/app/modules/permissions/mapper.server.ts
index 02c6dbff3..7b47301dc 100644
--- a/app/modules/permissions/mapper.server.ts
+++ b/app/modules/permissions/mapper.server.ts
@@ -1,7 +1,7 @@
import type { UserWithPlusTier } from "~/utils/kysely.server";
import { userDiscordIdIsAged } from "~/utils/users";
import type { Role } from "./types";
-import { isAdmin, isDev, isStaff, isSupporter } from "./utils";
+import { isAdmin, isDev, isQa, isStaff, isSupporter } from "./utils";
export function userRoles(
user: Pick<
@@ -30,6 +30,10 @@ export function userRoles(
result.push("DEV");
}
+ if (isQa(user)) {
+ result.push("QA");
+ }
+
if (typeof user.patronTier === "number") {
result.push("MINOR_SUPPORT");
}
diff --git a/app/modules/permissions/types.ts b/app/modules/permissions/types.ts
index 40f07127f..c4e2aabd7 100644
--- a/app/modules/permissions/types.ts
+++ b/app/modules/permissions/types.ts
@@ -14,6 +14,7 @@ export type Role =
| "CALENDAR_EVENT_ADDER"
| "TOURNAMENT_ADDER"
| "API_ACCESSER"
+ | "QA"
| "DEV"
| "SUPPORTER" // patrons of "Supporter" tier or higher
| "MINOR_SUPPORT"; // patrons of "Support" tier or higher
diff --git a/app/modules/permissions/utils.ts b/app/modules/permissions/utils.ts
index 96b81c84e..3c4b0e859 100644
--- a/app/modules/permissions/utils.ts
+++ b/app/modules/permissions/utils.ts
@@ -1,4 +1,9 @@
-import { ADMIN_ID, DEV_IDS, STAFF_IDS } from "~/features/admin/admin-constants";
+import {
+ ADMIN_ID,
+ DEV_IDS,
+ QA_IDS,
+ STAFF_IDS,
+} from "~/features/admin/admin-constants";
export function isAdmin(user?: { id: number }) {
return user?.id === ADMIN_ID;
@@ -16,6 +21,12 @@ export function isDev(user?: { id: number }) {
return DEV_IDS.includes(user.id);
}
+export function isQa(user?: { id: number }) {
+ if (!user) return false;
+
+ return QA_IDS.includes(user.id);
+}
+
export function isSupporter(user?: { patronTier: number | null }) {
return typeof user?.patronTier === "number" && user.patronTier >= 2;
}
diff --git a/app/routes.ts b/app/routes.ts
index dcd16c0b9..61ea74045 100644
--- a/app/routes.ts
+++ b/app/routes.ts
@@ -110,6 +110,19 @@ export default [
]),
]),
+ route(
+ "/trophies/:id/wins/:userId",
+ "features/trophies/routes/trophies.$id.wins.$userId.ts",
+ ),
+ route(
+ "/trophies/:id/tournaments",
+ "features/trophies/routes/trophies.$id.tournaments.ts",
+ ),
+ route("/trophies", "features/trophies/routes/trophies.tsx", [
+ route(":id", "features/trophies/routes/trophies.$id.tsx"),
+ ]),
+ route("/trophies/new", "features/trophies/routes/trophies.new.tsx"),
+
...prefix("/calendar", [
index("features/calendar/routes/calendar.tsx"),
route("new", "features/calendar/routes/calendar.new.tsx"),
diff --git a/app/styles/vars.css b/app/styles/vars.css
index 9d704dc89..b7f3f298c 100644
--- a/app/styles/vars.css
+++ b/app/styles/vars.css
@@ -80,7 +80,8 @@ cannot use currentColor inside data URLs
Any changes here NEED to be reflected in oklch-gamut.ts as well
*/
html.dark,
-html.dark [data-custom-theme] {
+html.dark [data-custom-theme],
+.dark-preview {
--color-base-0: oklch(100% var(--_base-c-0) var(--_base-h));
--color-base-1: oklch(95% var(--_base-c-1) var(--_base-h));
--color-base-2: oklch(90% var(--_base-c-2) var(--_base-h));
@@ -131,7 +132,8 @@ html.dark [data-custom-theme] {
}
html.light,
-html.light [data-custom-theme] {
+html.light [data-custom-theme],
+.light-preview {
--color-base-0: oklch(17% var(--_base-c-7) var(--_base-h));
--color-base-1: oklch(25% var(--_base-c-6) var(--_base-h));
--color-base-2: oklch(32% var(--_base-c-5) var(--_base-h));
@@ -186,7 +188,9 @@ These are the vars you mainly want to use
*/
html,
/* biome-ignore lint/style/noDescendingSpecificity: [data-custom-theme] re-declares theme vars for card subtrees; different properties than the html.dark/light blocks so no real override */
-[data-custom-theme] {
+[data-custom-theme],
+.dark-preview,
+.light-preview {
--color-text: var(--color-base-0);
--color-text-high: var(--color-base-3);
--color-text-inverse: var(--color-base-7);
diff --git a/app/utils/compression.test.ts b/app/utils/compression.test.ts
new file mode 100644
index 000000000..f72229b9e
--- /dev/null
+++ b/app/utils/compression.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from "vitest";
+import { compressToBase64, decompressFromBase64 } from "./compression";
+
+describe("compressToBase64 & decompressFromBase64", () => {
+ it("round-trips a string", () => {
+ const value = JSON.stringify({ name: "trophy", mesh: [1, 2, 3] });
+
+ expect(decompressFromBase64(compressToBase64(value))).toBe(value);
+ });
+
+ it("round-trips unicode content", () => {
+ const value = "tröphy \u{1f3c6} テスト";
+
+ expect(decompressFromBase64(compressToBase64(value))).toBe(value);
+ });
+
+ it("round-trips with the url safe alphabet", () => {
+ const value = "a".repeat(1000) + JSON.stringify({ b: [4, 5, 6] });
+ const compressed = compressToBase64(value, { urlSafe: true });
+
+ expect(compressed).not.toMatch(/[+/=]/);
+ expect(decompressFromBase64(compressed)).toBe(value);
+ });
+
+ it("returns null for corrupt input", () => {
+ expect(decompressFromBase64("!!!not base64!!!")).toBeNull();
+ expect(decompressFromBase64(btoa("not deflate data"))).toBeNull();
+ expect(decompressFromBase64("")).toBeNull();
+ });
+
+ it("returns null for truncated input", () => {
+ const compressed = compressToBase64("some longer input to compress");
+
+ expect(decompressFromBase64(compressed.slice(0, 4))).toBeNull();
+ });
+});
diff --git a/app/utils/compression.ts b/app/utils/compression.ts
new file mode 100644
index 000000000..332a87e6f
--- /dev/null
+++ b/app/utils/compression.ts
@@ -0,0 +1,42 @@
+import { deflateRaw, inflateRaw } from "pako";
+
+/**
+ * Compresses a string with raw deflate and encodes the result as base64.
+ * With `urlSafe` the output uses the URL-safe base64 alphabet without padding.
+ */
+export function compressToBase64(
+ value: string,
+ options?: { urlSafe?: boolean },
+) {
+ const bytes = deflateRaw(value, { level: 9 });
+ let binary = "";
+
+ for (const byte of bytes) {
+ binary += String.fromCharCode(byte);
+ }
+
+ const base64 = btoa(binary);
+ if (!options?.urlSafe) return base64;
+
+ return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+}
+
+/**
+ * Decompresses a base64 encoded (standard or URL-safe alphabet) raw deflate
+ * string. Returns `null` if the input is corrupt.
+ */
+export function decompressFromBase64(compressed: string) {
+ try {
+ const base64 = compressed.replace(/-/g, "+").replace(/_/g, "/");
+ const value = inflateRaw(
+ Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)),
+ { to: "string" },
+ );
+
+ if (!value) return null;
+
+ return value;
+ } catch {
+ return null;
+ }
+}
diff --git a/app/utils/i18n.ts b/app/utils/i18n.ts
index 680bbfa7e..dcf1e1df7 100644
--- a/app/utils/i18n.ts
+++ b/app/utils/i18n.ts
@@ -30,6 +30,7 @@ const ALL_NAMESPACES = [
"front",
"friends",
"settings",
+ "trophies",
"params",
"welcome",
] as const;
diff --git a/app/utils/kysely.server.ts b/app/utils/kysely.server.ts
index 7fd337381..b013824f5 100644
--- a/app/utils/kysely.server.ts
+++ b/app/utils/kysely.server.ts
@@ -173,6 +173,34 @@ export function tournamentLogoWithDefault(
);
}
+/**
+ * Subquery resolving to the event's earliest `CalendarEventDate` start time, or `null` when it has
+ * no dates. Correlates on `"CalendarEvent"."id"`. Alias it `.as("startTime")` when selecting it
+ * directly. Can also be passed to `orderBy` as is.
+ */
+export function calendarEventStartTime(
+ eb: ExpressionBuilder,
+) {
+ return eb
+ .selectFrom("CalendarEventDate")
+ .select((eb2) => eb2.fn.min("startsAt").as("startsAt"))
+ .whereRef("CalendarEventDate.eventId", "=", "CalendarEvent.id");
+}
+
+/**
+ * Subquery counting a tournament's non-placeholder teams. Correlates on `"Tournament"."id"`.
+ * Alias it `.as("teamsCount")` when selecting it directly.
+ */
+export function tournamentTeamCount(
+ eb: ExpressionBuilder,
+) {
+ return eb
+ .selectFrom("TournamentTeam")
+ .select((eb2) => eb2.fn.countAll().as("count"))
+ .whereRef("TournamentTeam.tournamentId", "=", "Tournament.id")
+ .where("TournamentTeam.isPlaceholder", "=", 0);
+}
+
/** Concats the file name (a bit misleadingly called `url` in the DB schema) with the root URL, giving the full URL for the image */
export function concatUserSubmittedImagePrefix(
expr: Expression,
diff --git a/app/utils/urls.ts b/app/utils/urls.ts
index 51e246eda..ab46ff591 100644
--- a/app/utils/urls.ts
+++ b/app/utils/urls.ts
@@ -68,6 +68,8 @@ export const SPR_INFO_URL =
"https://web.archive.org/web/20250513034545/https://www.pgstats.com/articles/introducing-spr-and-uf";
export const SPLATOON3_INK_SCHEDULES_URL =
"https://splatoon3.ink/data/schedules.json";
+export const PICOCAD2_WEB_VIEWER_URL =
+ "https://picocad2-web-viewer.hfcred.workers.dev/";
export const bskyUrl = (accountName: string) =>
`https://bsky.app/profile/${accountName}`;
@@ -86,6 +88,8 @@ export const WELCOME_PAGE = "/welcome";
export const SUPPORT_PAGE = "/support";
export const CONTRIBUTIONS_PAGE = "/contributions";
export const BADGES_PAGE = "/badges";
+export const TROPHIES_PAGE = "/trophies";
+export const NEW_TROPHY_PAGE = "/trophies/new";
export const BUILDS_PAGE = "/builds";
export const TEAM_SEARCH_PAGE = "/t";
export const NEW_TEAM_PAGE = "/t/new";
@@ -158,6 +162,14 @@ export const userCardNotePage = (userId: number) => `/user-card/${userId}/note`;
export const userReportPage = (userId: number) => `/user-report/${userId}`;
+export const trophyPage = (trophyId: number) => `${TROPHIES_PAGE}/${trophyId}`;
+
+export const trophyWinsPage = (args: { trophyId: number; userId: number }) =>
+ `${TROPHIES_PAGE}/${args.trophyId}/wins/${args.userId}`;
+
+export const trophyTournamentsPage = (trophyId: number) =>
+ `${TROPHIES_PAGE}/${trophyId}/tournaments`;
+
interface UserLinkArgs {
discordId: Tables["User"]["discordId"];
customUrl?: Tables["User"]["customUrl"];
diff --git a/db-test.sqlite3 b/db-test.sqlite3
index a687b38cac3e8909fff2fc9fc3b28aca526f6b51..4fb638a258450db74ae8098c666217dd9c141ab4 100644
GIT binary patch
delta 8439
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