mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-07-31 08:06:51 -05:00
Tournament Trophies (#3245)
This commit is contained in:
parent
293d6013be
commit
3df1f9ed19
27
app/components/DotPagination.module.css
Normal file
27
app/components/DotPagination.module.css
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
.pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-3);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
max-width: 20rem;
|
||||
margin: 0 auto;
|
||||
margin-top: var(--s-2);
|
||||
}
|
||||
|
||||
.button {
|
||||
background-color: var(--color-bg);
|
||||
border-radius: 100%;
|
||||
padding: var(--s-1);
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
border: var(--border-style);
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.buttonActive {
|
||||
color: var(--color-text-accent);
|
||||
background-color: var(--color-bg-high);
|
||||
border-color: var(--color-border-high);
|
||||
}
|
||||
38
app/components/DotPagination.tsx
Normal file
38
app/components/DotPagination.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { clsx } from "clsx";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import styles from "./DotPagination.module.css";
|
||||
|
||||
export function DotPagination({
|
||||
pagesCount,
|
||||
currentPage,
|
||||
setPage,
|
||||
ariaLabelPrefix,
|
||||
"data-testid": testId,
|
||||
className,
|
||||
}: {
|
||||
pagesCount: number;
|
||||
currentPage: number;
|
||||
setPage: (page: number) => void;
|
||||
ariaLabelPrefix: string;
|
||||
"data-testid"?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={clsx(styles.pagination, className)}>
|
||||
{Array.from({ length: pagesCount }, (_, i) => (
|
||||
<SendouButton
|
||||
key={i}
|
||||
variant="minimal"
|
||||
aria-label={`${ariaLabelPrefix} page ${i + 1}`}
|
||||
onPress={() => setPage(i + 1)}
|
||||
className={clsx(styles.button, {
|
||||
[styles.buttonActive]: currentPage === i + 1,
|
||||
})}
|
||||
data-testid={testId}
|
||||
>
|
||||
{i + 1}
|
||||
</SendouButton>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import { useUser } from "~/features/auth/core/user";
|
|||
import { useChatContext } from "~/features/chat/useChatContext";
|
||||
import { FriendMenu } from "~/features/friends/components/FriendMenu";
|
||||
import { SENDOUQ_ACTIVITY_LABEL } from "~/features/friends/friends-constants";
|
||||
import { canAccessTrophies } from "~/features/trophies/trophies-utils";
|
||||
import { useLayoutSize } from "~/hooks/useMainContentWidth";
|
||||
import { useUnseenFriendRequests } from "~/hooks/useUnseenFriendRequests";
|
||||
import type { RootLoaderData } from "~/root";
|
||||
|
|
@ -397,25 +398,29 @@ function MenuOverlay({
|
|||
|
||||
<nav aria-label={t("front:mobileNav.menu")}>
|
||||
<ul className={styles.navGrid}>
|
||||
{navItems.map((item) => (
|
||||
<li key={item.name}>
|
||||
<Link
|
||||
to={`/${item.url}`}
|
||||
className={styles.navItem}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className={styles.navItemImage}>
|
||||
<Image
|
||||
path={navIconUrl(item.name)}
|
||||
height={32}
|
||||
width={32}
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<span>{t(`common:pages.${item.name}` as any)}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
{navItems
|
||||
.filter(
|
||||
(item) => item.name !== "trophies" || canAccessTrophies(user),
|
||||
)
|
||||
.map((item) => (
|
||||
<li key={item.name}>
|
||||
<Link
|
||||
to={`/${item.url}`}
|
||||
className={styles.navItem}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className={styles.navItemImage}>
|
||||
<Image
|
||||
path={navIconUrl(item.name)}
|
||||
height={32}
|
||||
width={32}
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<span>{t(`common:pages.${item.name}` as any)}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,24 @@
|
|||
:root {
|
||||
--tier-bg-1: hsl(45, 100%, 47%);
|
||||
--tier-bg-2: hsl(280, 90%, 44%);
|
||||
--tier-bg-3: hsl(280, 65%, 52%);
|
||||
--tier-bg-4: hsl(212, 95%, 44%);
|
||||
--tier-bg-5: hsl(212, 80%, 51%);
|
||||
--tier-bg-6: hsl(145, 80%, 34%);
|
||||
--tier-bg-7: hsl(145, 60%, 41%);
|
||||
--tier-bg-8: hsl(220, 10%, 40%);
|
||||
--tier-bg-9: hsl(220, 8%, 49%);
|
||||
--tier-text-1: hsl(45, 100%, 12%);
|
||||
--tier-text-2: hsl(280, 100%, 94%);
|
||||
--tier-text-3: hsl(280, 100%, 95%);
|
||||
--tier-text-4: hsl(212, 100%, 93%);
|
||||
--tier-text-5: hsl(212, 100%, 95%);
|
||||
--tier-text-6: hsl(145, 85%, 92%);
|
||||
--tier-text-7: hsl(145, 75%, 94%);
|
||||
--tier-text-8: hsl(220, 15%, 92%);
|
||||
--tier-text-9: hsl(220, 15%, 95%);
|
||||
}
|
||||
|
||||
.pill {
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-bold);
|
||||
|
|
@ -11,46 +32,95 @@
|
|||
}
|
||||
|
||||
.tierX {
|
||||
background-color: hsl(45, 100%, 30%);
|
||||
color: hsl(45, 100%, 90%);
|
||||
--_polished-bg: var(--tier-bg-1);
|
||||
background-color: var(--tier-bg-1);
|
||||
color: var(--tier-text-1);
|
||||
}
|
||||
|
||||
.tierSPlus {
|
||||
background-color: hsl(280, 60%, 35%);
|
||||
color: hsl(280, 80%, 90%);
|
||||
--_polished-bg: var(--tier-bg-2);
|
||||
background-color: var(--tier-bg-2);
|
||||
color: var(--tier-text-2);
|
||||
}
|
||||
|
||||
.tierS {
|
||||
background-color: hsl(280, 50%, 40%);
|
||||
color: hsl(280, 70%, 92%);
|
||||
--_polished-bg: var(--tier-bg-3);
|
||||
background-color: var(--tier-bg-3);
|
||||
color: var(--tier-text-3);
|
||||
}
|
||||
|
||||
.tierAPlus {
|
||||
background-color: hsl(210, 60%, 35%);
|
||||
color: hsl(210, 80%, 90%);
|
||||
background-color: var(--tier-bg-4);
|
||||
color: var(--tier-text-4);
|
||||
}
|
||||
|
||||
.tierA {
|
||||
background-color: hsl(210, 50%, 40%);
|
||||
color: hsl(210, 70%, 92%);
|
||||
background-color: var(--tier-bg-5);
|
||||
color: var(--tier-text-5);
|
||||
}
|
||||
|
||||
.tierBPlus {
|
||||
background-color: hsl(140, 45%, 32%);
|
||||
color: hsl(140, 60%, 90%);
|
||||
background-color: var(--tier-bg-6);
|
||||
color: var(--tier-text-6);
|
||||
}
|
||||
|
||||
.tierB {
|
||||
background-color: hsl(140, 35%, 38%);
|
||||
color: hsl(140, 50%, 92%);
|
||||
background-color: var(--tier-bg-7);
|
||||
color: var(--tier-text-7);
|
||||
}
|
||||
|
||||
.tierCPlus {
|
||||
background-color: hsl(0, 0%, 35%);
|
||||
color: hsl(0, 0%, 90%);
|
||||
background-color: var(--tier-bg-8);
|
||||
color: var(--tier-text-8);
|
||||
}
|
||||
|
||||
.tierC {
|
||||
background-color: hsl(0, 0%, 40%);
|
||||
color: hsl(0, 0%, 92%);
|
||||
background-color: var(--tier-bg-9);
|
||||
color: var(--tier-text-9);
|
||||
}
|
||||
|
||||
.polished {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-image: linear-gradient(
|
||||
160deg,
|
||||
hsl(from var(--_polished-bg) calc(h + 2) s calc(l + 14)),
|
||||
var(--_polished-bg) 50%,
|
||||
hsl(from var(--_polished-bg) calc(h - 3) s calc(l - 9))
|
||||
);
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
115deg,
|
||||
transparent 35%,
|
||||
hsl(0, 0%, 100%, 0.5) 50%,
|
||||
transparent 65%
|
||||
);
|
||||
transform: translateX(-100%);
|
||||
animation: shine-sweep 6s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shine-sweep {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
12% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.polished::after {
|
||||
animation: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ const TIER_STYLE_CLASS: Record<number, string> = {
|
|||
9: styles.tierC,
|
||||
};
|
||||
|
||||
const POLISHED_TIERS = [1, 2, 3];
|
||||
|
||||
export function TierPill({
|
||||
tier,
|
||||
isTentative = false,
|
||||
|
|
@ -29,7 +31,9 @@ export function TierPill({
|
|||
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.pill, tierClass)}
|
||||
className={clsx(styles.pill, tierClass, {
|
||||
[styles.polished]: POLISHED_TIERS.includes(tier),
|
||||
})}
|
||||
data-testid={isTentative ? "tentative-tier" : "confirmed-tier"}
|
||||
title={
|
||||
isTentative
|
||||
|
|
|
|||
122
app/components/elements/OrganizationSearch.tsx
Normal file
122
app/components/elements/OrganizationSearch.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
import { ListBoxItem, type SelectProps } from "react-aria-components";
|
||||
import { useFetcher } from "react-router";
|
||||
import type { SearchLoaderData } from "~/features/search/routes/search";
|
||||
import { SearchSelect } from "./SearchSelect";
|
||||
import searchSelectStyles from "./SearchSelect.module.css";
|
||||
import selectStyles from "./Select.module.css";
|
||||
import { useEntitySearch } from "./useEntitySearch";
|
||||
|
||||
export type OrganizationSearchResult = Extract<
|
||||
NonNullable<SearchLoaderData>["results"][number],
|
||||
{ type: "organization" }
|
||||
>;
|
||||
|
||||
interface OrganizationSearchProps<T extends object>
|
||||
extends Omit<SelectProps<T>, "children" | "onChange"> {
|
||||
name?: string;
|
||||
label?: string;
|
||||
bottomText?: string;
|
||||
errorText?: string;
|
||||
initialOrganizationId?: number;
|
||||
onChange?: (organization: OrganizationSearchResult | null) => void;
|
||||
}
|
||||
|
||||
export const OrganizationSearch = React.forwardRef(function OrganizationSearch<
|
||||
T extends object,
|
||||
>(
|
||||
{
|
||||
name,
|
||||
label,
|
||||
bottomText,
|
||||
errorText,
|
||||
initialOrganizationId,
|
||||
onChange,
|
||||
...rest
|
||||
}: OrganizationSearchProps<T>,
|
||||
ref?: React.Ref<HTMLButtonElement>,
|
||||
) {
|
||||
const initialOrganization = useInitialOrganization(initialOrganizationId);
|
||||
|
||||
const search = useEntitySearch<OrganizationSearchResult>({
|
||||
buildUrl: (query) => `/search?q=${query}&type=organizations&limit=6`,
|
||||
parseResults: (data, query) =>
|
||||
parseOrganizationResults(data, query, initialOrganization),
|
||||
initialItem: initialOrganization,
|
||||
initialSelectedId: initialOrganizationId,
|
||||
onChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<SearchSelect
|
||||
{...rest}
|
||||
name={name}
|
||||
label={label}
|
||||
bottomText={bottomText}
|
||||
errorText={errorText}
|
||||
ariaLabel="Organization search"
|
||||
inputTestId="organization-search-input"
|
||||
i18nKey="organizationSearch"
|
||||
search={search}
|
||||
buttonRef={ref}
|
||||
renderItem={(item) => <OrganizationItem item={item} />}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
function parseOrganizationResults(
|
||||
data: unknown,
|
||||
query: string,
|
||||
initialOrganization?: OrganizationSearchResult,
|
||||
): OrganizationSearchResult[] | null {
|
||||
const searchData = data as SearchLoaderData;
|
||||
if (!searchData || searchData.query !== query) return null;
|
||||
return searchData.results
|
||||
.filter(
|
||||
(result): result is OrganizationSearchResult =>
|
||||
result.type === "organization",
|
||||
)
|
||||
.filter((org) => org.id !== initialOrganization?.id);
|
||||
}
|
||||
|
||||
function useInitialOrganization(initialOrganizationId?: number) {
|
||||
const fetcher = useFetcher<SearchLoaderData>();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!initialOrganizationId || fetcher.state !== "idle" || fetcher.data) {
|
||||
return;
|
||||
}
|
||||
fetcher.load(
|
||||
`/search?q=${initialOrganizationId}&type=organizations&limit=1`,
|
||||
);
|
||||
}, [initialOrganizationId, fetcher]);
|
||||
|
||||
return fetcher.data?.results.find(
|
||||
(result): result is OrganizationSearchResult =>
|
||||
result.type === "organization",
|
||||
);
|
||||
}
|
||||
|
||||
function OrganizationItem({ item }: { item: OrganizationSearchResult }) {
|
||||
return (
|
||||
<ListBoxItem
|
||||
id={item.id}
|
||||
textValue={item.name}
|
||||
className={({ isFocused, isSelected }) =>
|
||||
clsx(searchSelectStyles.item, {
|
||||
[selectStyles.itemFocused]: isFocused,
|
||||
[selectStyles.itemSelected]: isSelected,
|
||||
})
|
||||
}
|
||||
data-testid="organization-search-item"
|
||||
>
|
||||
<div className={searchSelectStyles.itemTextsContainer}>
|
||||
{item.name}
|
||||
<div className={searchSelectStyles.itemAdditionalText}>
|
||||
/{item.slug}
|
||||
</div>
|
||||
</div>
|
||||
</ListBoxItem>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { Config } from "~/config";
|
|||
import { NZAP_TEST_ID } from "~/db/seed/constants";
|
||||
import { ADMIN_ID } from "~/features/admin/admin-constants";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { canAccessTrophies } from "~/features/trophies/trophies-utils";
|
||||
import {
|
||||
impersonateUrl,
|
||||
navIconUrl,
|
||||
|
|
@ -22,6 +23,10 @@ const DEV_IMPERSONATE_ITEMS = [
|
|||
{ name: "Logged out", icon: "log_in", action: STOP_IMPERSONATING_URL },
|
||||
] as const;
|
||||
|
||||
const DEV_LINK_ITEMS = [
|
||||
{ name: "Components", icon: "settings", url: "/components" },
|
||||
] as const;
|
||||
|
||||
const NAV_CATEGORIES = [
|
||||
{
|
||||
name: "play",
|
||||
|
|
@ -61,7 +66,7 @@ const NAV_CATEGORIES = [
|
|||
{ name: "art", url: "art" },
|
||||
{ name: "articles", url: "a" },
|
||||
{ name: "vods", url: "vods" },
|
||||
{ name: "badges", url: "badges" },
|
||||
{ name: "trophies", url: "trophies" },
|
||||
{ name: "links", url: "links" },
|
||||
{ name: "plus", url: "plus/suggestions" },
|
||||
],
|
||||
|
|
@ -125,6 +130,25 @@ function DevMenu() {
|
|||
</button>
|
||||
</Form>
|
||||
))}
|
||||
{DEV_LINK_ITEMS.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.url}
|
||||
className={styles.menuItem}
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
setIsPreviewSuppressed(true);
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
path={navIconUrl(item.icon)}
|
||||
alt=""
|
||||
size={20}
|
||||
className={styles.menuItemIcon}
|
||||
/>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</SendouPopover>
|
||||
{!isOpen && !isPreviewSuppressed ? (
|
||||
|
|
@ -149,6 +173,19 @@ function DevMenu() {
|
|||
</button>
|
||||
</Form>
|
||||
))}
|
||||
{DEV_LINK_ITEMS.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.url}
|
||||
className={styles.previewIcon}
|
||||
title={item.name}
|
||||
aria-label={item.name}
|
||||
tabIndex={-1}
|
||||
onClick={() => setIsPreviewSuppressed(true)}
|
||||
>
|
||||
<Image path={navIconUrl(item.icon)} alt="" size={20} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -167,9 +204,11 @@ function CategoryMenu({
|
|||
const isStaff = user?.roles.includes("STAFF") ?? false;
|
||||
const showStaffOnly = isStaff || process.env.NODE_ENV === "development";
|
||||
|
||||
const visibleItems = category.items.filter(
|
||||
(item) => !("staffOnly" in item) || showStaffOnly,
|
||||
);
|
||||
const visibleItems = category.items.filter((item) => {
|
||||
if ("staffOnly" in item && !showStaffOnly) return false;
|
||||
if (item.name === "trophies" && !canAccessTrophies(user)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.menuWrapper}>
|
||||
|
|
|
|||
|
|
@ -59,8 +59,8 @@ export const navItems = [
|
|||
prefetch: false,
|
||||
},
|
||||
{
|
||||
name: "badges",
|
||||
url: "badges",
|
||||
name: "trophies",
|
||||
url: "trophies",
|
||||
prefetch: false,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -34,10 +34,17 @@ import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"
|
|||
import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
|
||||
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
|
||||
import { PRESET_COLORS } from "~/features/tier-list-maker/tier-list-maker-constants";
|
||||
import * as XRankPlacementRepository from "~/features/top-search/XRankPlacementRepository.server";
|
||||
import type { TournamentTierNumber } from "~/features/tournament/core/tiering";
|
||||
import type { TournamentMapPickingStyle } from "~/features/tournament/tournament-constants";
|
||||
import { clearAllTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server";
|
||||
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
|
||||
import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
|
||||
import {
|
||||
SUPPORTER_TROPHY_CODE,
|
||||
XP_TROPHY_CODE_PREFIX,
|
||||
} from "~/features/trophies/trophies-constants";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { USER_REPORT_CATEGORIES } from "~/features/user-report/user-report-constants";
|
||||
import * as VodRepository from "~/features/vods/VodRepository.server";
|
||||
|
|
@ -95,6 +102,7 @@ import {
|
|||
STAFF_TEST_ID,
|
||||
} from "./constants";
|
||||
import placements from "./placements.json";
|
||||
import trophies from "./trophies.json";
|
||||
|
||||
const SENDOUQ_DEFAULT_MAPS: Record<
|
||||
ModeShort,
|
||||
|
|
@ -254,6 +262,13 @@ const basicSeeds = (variation?: SeedVariation | null) => [
|
|||
splatoonRotations,
|
||||
variation === "FINALIZED_BRACKET" ? finalizedBracket : undefined,
|
||||
variation === "AB_RR" ? abDivisionsTournament : undefined,
|
||||
trophiesToDb,
|
||||
trophyOwners,
|
||||
trophyWinsTournament,
|
||||
upcomingTrophyTournament,
|
||||
pendingTrophiesToDb,
|
||||
assignTrophyToTournament,
|
||||
specialTrophies,
|
||||
];
|
||||
|
||||
export async function seed(variation?: SeedVariation | null) {
|
||||
|
|
@ -606,6 +621,11 @@ async function wipeDB() {
|
|||
"ModNote",
|
||||
"Friendship",
|
||||
"FriendRequest",
|
||||
"PendingTrophyApproval",
|
||||
"PendingTrophy",
|
||||
"TrophyOwner",
|
||||
"SpecialTrophyOwner",
|
||||
"Trophy",
|
||||
"User",
|
||||
"PlusSuggestion",
|
||||
"PlusVote",
|
||||
|
|
@ -1184,6 +1204,548 @@ async function badgeManagers() {
|
|||
}
|
||||
}
|
||||
|
||||
async function trophiesToDb() {
|
||||
for (const [name, model] of Object.entries(trophies)) {
|
||||
await db
|
||||
.insertInto("Trophy")
|
||||
.values({
|
||||
name,
|
||||
model,
|
||||
organizationId: 1,
|
||||
creatorId: ADMIN_ID,
|
||||
managerId: NZAP_TEST_ID,
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
async function trophyOwners() {
|
||||
const trophyIds = (await db.selectFrom("Trophy").select("id").execute()).map(
|
||||
(row) => row.id,
|
||||
);
|
||||
|
||||
const tournamentIds = (
|
||||
await db.selectFrom("Tournament").select("id").execute()
|
||||
).map((row) => row.id);
|
||||
|
||||
let userIds = (
|
||||
await db
|
||||
.selectFrom("User")
|
||||
.select("id")
|
||||
.where("id", "not in", [NZAP_TEST_ID, ADMIN_ID])
|
||||
.execute()
|
||||
).map((row) => row.id);
|
||||
|
||||
const randomTier = () =>
|
||||
faker.helpers.maybe(() => faker.number.int({ min: 1, max: 9 }), {
|
||||
probability: 0.85,
|
||||
}) ?? null;
|
||||
|
||||
const usedCombinations = new Set<string>();
|
||||
const insertOwner = async (
|
||||
trophyId: number,
|
||||
userId: number,
|
||||
tournamentId: number,
|
||||
) => {
|
||||
const key = `${trophyId}-${userId}-${tournamentId}`;
|
||||
if (usedCombinations.has(key)) return;
|
||||
usedCombinations.add(key);
|
||||
await db
|
||||
.insertInto("TrophyOwner")
|
||||
.values({
|
||||
trophyId,
|
||||
userId,
|
||||
tournamentId,
|
||||
tier: randomTier(),
|
||||
})
|
||||
.execute();
|
||||
};
|
||||
|
||||
for (const trophyId of trophyIds) {
|
||||
userIds = faker.helpers.shuffle(userIds);
|
||||
const ownerCount = faker.number.int({ min: 1, max: 8 });
|
||||
|
||||
for (let i = 0; i < ownerCount; i++) {
|
||||
const userId = userIds.shift()!;
|
||||
const copies = faker.number.int({ min: 1, max: 3 });
|
||||
const shuffledTournaments = faker.helpers.shuffle([...tournamentIds]);
|
||||
|
||||
for (let j = 0; j < copies && j < shuffledTournaments.length; j++) {
|
||||
await insertOwner(trophyId, userId, shuffledTournaments[j]);
|
||||
}
|
||||
|
||||
userIds.push(userId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const trophyId of trophyIds) {
|
||||
const shuffledTournaments = faker.helpers.shuffle([...tournamentIds]);
|
||||
const copies = faker.number.int({ min: 1, max: 3 });
|
||||
|
||||
for (let i = 0; i < copies && i < shuffledTournaments.length; i++) {
|
||||
await insertOwner(trophyId, ADMIN_ID, shuffledTournaments[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const TROPHY_TOURNAMENT_ID = 9;
|
||||
const TROPHY_EVENT_ID = 209;
|
||||
const TROPHY_TEAM_ID_OFFSET = 800;
|
||||
// bracket rows use explicit non-positive ids so brackets started at runtime
|
||||
// (and tests referencing their seeded match ids) keep autoincrementing from 1
|
||||
const TROPHY_STAGE_ID = 0;
|
||||
const TROPHY_GROUP_ID = 0;
|
||||
const TROPHY_ROUND_ID_OFFSET = -3;
|
||||
const TROPHY_MATCH_ID_OFFSET = -7;
|
||||
const TROPHY_GAME_RESULT_ID_OFFSET = -14;
|
||||
|
||||
async function trophyWinsTournament() {
|
||||
await insertTournamentWithId({
|
||||
id: TROPHY_TOURNAMENT_ID,
|
||||
mapPickingStyle: "AUTO_ALL",
|
||||
settings: JSON.stringify({
|
||||
isRanked: true,
|
||||
bracketProgression: [
|
||||
{
|
||||
type: "single_elimination",
|
||||
name: "Bracket",
|
||||
requiresCheckIn: false,
|
||||
settings: { thirdPlaceMatch: false },
|
||||
},
|
||||
],
|
||||
}),
|
||||
isFinalized: 1,
|
||||
tier: 3,
|
||||
});
|
||||
|
||||
await insertCalendarEventWithId({
|
||||
id: TROPHY_EVENT_ID,
|
||||
name: "Trophy Cup",
|
||||
description: "Finished tournament with a trophy prize",
|
||||
discordInviteCode: "test",
|
||||
bracketUrl: "https://example.com",
|
||||
authorId: ADMIN_ID,
|
||||
tournamentId: TROPHY_TOURNAMENT_ID,
|
||||
trophyId: 1,
|
||||
});
|
||||
|
||||
await db
|
||||
.insertInto("CalendarEventDate")
|
||||
.values({
|
||||
eventId: TROPHY_EVENT_ID,
|
||||
startsAt: dateToDatabaseTimestamp(
|
||||
new Date(Date.now() - 1000 * 60 * 60 * 24 * 21),
|
||||
),
|
||||
})
|
||||
.execute();
|
||||
|
||||
const userIds = await userIdsInAscendingOrderById();
|
||||
const teamNames = [
|
||||
"Splat Society",
|
||||
"Ink Theory",
|
||||
"Booyah Brigade",
|
||||
"Chargers Anonymous",
|
||||
"Squid Parts",
|
||||
"Woomy Council",
|
||||
"Roller Coalition",
|
||||
"Last Splash",
|
||||
];
|
||||
const teamMembers = new Map<number, number[]>();
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const teamId = TROPHY_TEAM_ID_OFFSET + i + 1;
|
||||
|
||||
await insertTournamentTeamWithId({
|
||||
id: teamId,
|
||||
name: teamNames[i],
|
||||
createdAt: dateToDatabaseTimestamp(new Date()),
|
||||
tournamentId: TROPHY_TOURNAMENT_ID,
|
||||
inviteCode: shortNanoid(),
|
||||
seed: i + 1,
|
||||
});
|
||||
|
||||
await db
|
||||
.insertInto("TournamentTeamCheckIn")
|
||||
.values({
|
||||
tournamentTeamId: teamId,
|
||||
checkedInAt: dateToDatabaseTimestamp(new Date()),
|
||||
})
|
||||
.execute();
|
||||
|
||||
const members: number[] = [];
|
||||
for (let j = 0; j < 4; j++) {
|
||||
const userId = userIds.shift()!;
|
||||
members.push(userId);
|
||||
|
||||
await db
|
||||
.insertInto("TournamentTeamMember")
|
||||
.values({
|
||||
tournamentTeamId: teamId,
|
||||
userId,
|
||||
createdAt: dateToDatabaseTimestamp(new Date()),
|
||||
role: j === 0 ? "OWNER" : "REGULAR",
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
teamMembers.set(teamId, members);
|
||||
}
|
||||
|
||||
// Bracket structure
|
||||
const stageId = TROPHY_STAGE_ID;
|
||||
await insertTournamentStageWithId({
|
||||
id: stageId,
|
||||
tournamentId: TROPHY_TOURNAMENT_ID,
|
||||
name: "Bracket",
|
||||
number: 1,
|
||||
type: "single_elimination",
|
||||
settings: JSON.stringify({ thirdPlaceMatch: false }),
|
||||
});
|
||||
|
||||
const groupId = TROPHY_GROUP_ID;
|
||||
await insertTournamentGroupWithId({ id: groupId, stageId, number: 1 });
|
||||
|
||||
const roundMaps = JSON.stringify({ count: 3, type: "BEST_OF" });
|
||||
|
||||
const roundIds: number[] = [];
|
||||
for (let r = 1; r <= 3; r++) {
|
||||
const roundId = TROPHY_ROUND_ID_OFFSET + r;
|
||||
await insertTournamentRoundWithId({
|
||||
id: roundId,
|
||||
stageId,
|
||||
groupId,
|
||||
number: r,
|
||||
maps: roundMaps,
|
||||
});
|
||||
roundIds.push(roundId);
|
||||
}
|
||||
|
||||
const t = (seed: number) => TROPHY_TEAM_ID_OFFSET + seed;
|
||||
|
||||
// SE 8-team bracket: standard seeding, higher seed always wins
|
||||
const matches = [
|
||||
{ round: 0, number: 1, team1: t(1), team2: t(8), winner: t(1) },
|
||||
{ round: 0, number: 2, team1: t(4), team2: t(5), winner: t(4) },
|
||||
{ round: 0, number: 3, team1: t(2), team2: t(7), winner: t(2) },
|
||||
{ round: 0, number: 4, team1: t(3), team2: t(6), winner: t(3) },
|
||||
{ round: 1, number: 1, team1: t(1), team2: t(4), winner: t(1) },
|
||||
{ round: 1, number: 2, team1: t(2), team2: t(3), winner: t(2) },
|
||||
{ round: 2, number: 1, team1: t(1), team2: t(2), winner: t(1) },
|
||||
];
|
||||
|
||||
const weaponPools = new Map<number, MainWeaponId[]>();
|
||||
const weaponPoolFor = (userId: number) => {
|
||||
const existing = weaponPools.get(userId);
|
||||
if (existing) return existing;
|
||||
|
||||
const pool = faker.helpers.arrayElements(canonicalMainWeaponIds, {
|
||||
min: 1,
|
||||
max: 3,
|
||||
});
|
||||
weaponPools.set(userId, pool);
|
||||
return pool;
|
||||
};
|
||||
|
||||
let gameResultId = TROPHY_GAME_RESULT_ID_OFFSET;
|
||||
for (const [matchIndex, m] of matches.entries()) {
|
||||
const matchId = TROPHY_MATCH_ID_OFFSET + matchIndex + 1;
|
||||
await insertTournamentMatchWithId({
|
||||
id: matchId,
|
||||
stageId,
|
||||
groupId,
|
||||
roundId: roundIds[m.round],
|
||||
number: m.number,
|
||||
opponentOne: JSON.stringify({
|
||||
id: m.team1,
|
||||
score: m.winner === m.team1 ? 2 : 0,
|
||||
}),
|
||||
opponentTwo: JSON.stringify({
|
||||
id: m.team2,
|
||||
score: m.winner === m.team2 ? 2 : 0,
|
||||
}),
|
||||
winnerSide: m.winner === m.team1 ? "opponent1" : "opponent2",
|
||||
});
|
||||
|
||||
// 2 game results (2-0 sweep) with weapons reported for every player
|
||||
for (let g = 1; g <= 2; g++) {
|
||||
gameResultId += 1;
|
||||
await insertTournamentMatchGameResultWithId({
|
||||
id: gameResultId,
|
||||
matchId,
|
||||
mode: "SZ",
|
||||
number: g,
|
||||
reporterId: ADMIN_ID,
|
||||
source: "DEFAULT",
|
||||
stageId: 1,
|
||||
winnerTeamId: m.winner,
|
||||
});
|
||||
|
||||
for (const teamId of [m.team1, m.team2]) {
|
||||
for (const userId of teamMembers.get(teamId)!) {
|
||||
await db
|
||||
.insertInto("ReportedWeapon")
|
||||
.values({
|
||||
tournamentMatchId: matchId,
|
||||
mapIndex: g - 1,
|
||||
weaponSplId: faker.helpers.arrayElement(weaponPoolFor(userId)),
|
||||
userId,
|
||||
createdAt: dateToDatabaseTimestamp(new Date()),
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const placements = [
|
||||
{ teamSeed: 1, placement: 1, setResults: ["W", "W", "W"] },
|
||||
{ teamSeed: 2, placement: 2, setResults: ["W", "W", "L"] },
|
||||
{ teamSeed: 3, placement: 3, setResults: ["W", "L"] },
|
||||
{ teamSeed: 4, placement: 3, setResults: ["W", "L"] },
|
||||
{ teamSeed: 5, placement: 5, setResults: ["L"] },
|
||||
{ teamSeed: 6, placement: 5, setResults: ["L"] },
|
||||
{ teamSeed: 7, placement: 5, setResults: ["L"] },
|
||||
{ teamSeed: 8, placement: 5, setResults: ["L"] },
|
||||
];
|
||||
|
||||
for (const p of placements) {
|
||||
const teamId = t(p.teamSeed);
|
||||
|
||||
for (const [memberIndex, userId] of teamMembers.get(teamId)!.entries()) {
|
||||
const setResults = p.setResults.map((result) =>
|
||||
memberIndex > 0 && faker.number.float(1) > 0.75 ? null : result,
|
||||
);
|
||||
const spDiff =
|
||||
Math.round(
|
||||
(p.placement === 1
|
||||
? faker.number.float({ min: 80, max: 220 })
|
||||
: faker.number.float({ min: -120, max: 60 })) * 10,
|
||||
) / 10;
|
||||
|
||||
await db
|
||||
.insertInto("TournamentResult")
|
||||
.values({
|
||||
tournamentId: TROPHY_TOURNAMENT_ID,
|
||||
tournamentTeamId: teamId,
|
||||
userId,
|
||||
placement: p.placement,
|
||||
participantCount: 8,
|
||||
setResults: JSON.stringify(setResults),
|
||||
spDiff,
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
for (const userId of teamMembers.get(t(1))!) {
|
||||
await db
|
||||
.insertInto("TrophyOwner")
|
||||
.values({
|
||||
trophyId: 1,
|
||||
userId,
|
||||
tournamentId: TROPHY_TOURNAMENT_ID,
|
||||
tier: 3,
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
const UPCOMING_TROPHY_TOURNAMENT_ID = 10;
|
||||
const UPCOMING_TROPHY_EVENT_ID = 210;
|
||||
|
||||
async function upcomingTrophyTournament() {
|
||||
await insertTournamentWithId({
|
||||
id: UPCOMING_TROPHY_TOURNAMENT_ID,
|
||||
mapPickingStyle: "AUTO_ALL",
|
||||
settings: JSON.stringify({
|
||||
isRanked: true,
|
||||
bracketProgression: [
|
||||
{
|
||||
type: "single_elimination",
|
||||
name: "Bracket",
|
||||
requiresCheckIn: false,
|
||||
settings: { thirdPlaceMatch: false },
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
await insertCalendarEventWithId({
|
||||
id: UPCOMING_TROPHY_EVENT_ID,
|
||||
name: "Trophy Cup 2",
|
||||
description: "Upcoming tournament with a trophy prize",
|
||||
discordInviteCode: "test",
|
||||
bracketUrl: "https://example.com",
|
||||
authorId: ADMIN_ID,
|
||||
tournamentId: UPCOMING_TROPHY_TOURNAMENT_ID,
|
||||
trophyId: 1,
|
||||
});
|
||||
|
||||
await db
|
||||
.insertInto("CalendarEventDate")
|
||||
.values({
|
||||
eventId: UPCOMING_TROPHY_EVENT_ID,
|
||||
startsAt: dateToDatabaseTimestamp(
|
||||
new Date(Date.now() + 1000 * 60 * 60 * 24 * 10),
|
||||
),
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function pendingTrophiesToDb() {
|
||||
const userIds = (
|
||||
await db
|
||||
.selectFrom("User")
|
||||
.select("id")
|
||||
.where("id", "!=", ADMIN_ID)
|
||||
.orderBy(sql`random()`)
|
||||
.limit(10)
|
||||
.execute()
|
||||
).map((row) => row.id);
|
||||
|
||||
const orgIds = (
|
||||
await db
|
||||
.selectFrom("TournamentOrganization")
|
||||
.select("id")
|
||||
.limit(5)
|
||||
.execute()
|
||||
).map((row) => row.id);
|
||||
|
||||
const trophyEntries = Object.entries(trophies);
|
||||
|
||||
const insertPending = (values: {
|
||||
name: string;
|
||||
model: string;
|
||||
createdAt: number;
|
||||
declineReason?: string | null;
|
||||
declinedAt?: number | null;
|
||||
declinedByUserId?: number | null;
|
||||
}) =>
|
||||
db
|
||||
.insertInto("PendingTrophy")
|
||||
.values({
|
||||
name: values.name,
|
||||
model: values.model,
|
||||
description: faker.lorem.sentence(),
|
||||
organizationId: faker.helpers.arrayElement(orgIds),
|
||||
submitterUserId: faker.helpers.arrayElement(userIds),
|
||||
createdAt: values.createdAt,
|
||||
declineReason: values.declineReason ?? null,
|
||||
declinedAt: values.declinedAt ?? null,
|
||||
declinedByUserId: values.declinedByUserId ?? null,
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const insertApproval = (values: {
|
||||
pendingTrophyId: number;
|
||||
userId: number;
|
||||
createdAt: number;
|
||||
}) => db.insertInto("PendingTrophyApproval").values(values).execute();
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// 5 pending
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const [trophyName, model] = trophyEntries[i % trophyEntries.length];
|
||||
await insertPending({
|
||||
name: `Pending ${trophyName} ${i + 1}`,
|
||||
model,
|
||||
createdAt: now - i * 3600,
|
||||
});
|
||||
}
|
||||
|
||||
// 2 with one approval
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const [trophyName, model] = trophyEntries[(i + 5) % trophyEntries.length];
|
||||
const pending = await insertPending({
|
||||
name: `Partial ${trophyName} ${i + 1}`,
|
||||
model,
|
||||
createdAt: now - (i + 5) * 3600,
|
||||
});
|
||||
|
||||
await insertApproval({
|
||||
pendingTrophyId: pending.id,
|
||||
userId: ADMIN_ID,
|
||||
createdAt: now - i * 1800,
|
||||
});
|
||||
}
|
||||
|
||||
// 3 fully approved
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const [trophyName, model] = trophyEntries[(i + 7) % trophyEntries.length];
|
||||
const pending = await insertPending({
|
||||
name: `Accepted ${trophyName} ${i + 1}`,
|
||||
model,
|
||||
createdAt: now - (i + 7) * 3600,
|
||||
});
|
||||
|
||||
await insertApproval({
|
||||
pendingTrophyId: pending.id,
|
||||
userId: ADMIN_ID,
|
||||
createdAt: now - (i + 1) * 1800,
|
||||
});
|
||||
await insertApproval({
|
||||
pendingTrophyId: pending.id,
|
||||
userId: NZAP_TEST_ID,
|
||||
createdAt: now - i * 1800,
|
||||
});
|
||||
}
|
||||
|
||||
// 3 declined
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const [trophyName, model] = trophyEntries[(i + 10) % trophyEntries.length];
|
||||
await insertPending({
|
||||
name: `Declined ${trophyName} ${i + 1}`,
|
||||
model,
|
||||
createdAt: now - (i + 10) * 3600,
|
||||
declineReason: faker.lorem.sentence(),
|
||||
declinedAt: now - i * 1800,
|
||||
declinedByUserId: ADMIN_ID,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function assignTrophyToTournament() {
|
||||
const PICNIC_EVENT_ID = 201;
|
||||
await db
|
||||
.deleteFrom("CalendarEventBadge")
|
||||
.where("eventId", "=", PICNIC_EVENT_ID)
|
||||
.execute();
|
||||
await db
|
||||
.updateTable("CalendarEvent")
|
||||
.set({ trophyId: 1 })
|
||||
.where("id", "=", PICNIC_EVENT_ID)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function specialTrophies() {
|
||||
const models = Object.values(trophies);
|
||||
|
||||
const specialTrophyValues = [
|
||||
{
|
||||
name: "Supporter",
|
||||
model: models[0 % models.length],
|
||||
code: SUPPORTER_TROPHY_CODE,
|
||||
},
|
||||
{
|
||||
name: "3000 X Power",
|
||||
model: models[1 % models.length],
|
||||
code: `${XP_TROPHY_CODE_PREFIX}3000`,
|
||||
},
|
||||
{
|
||||
name: "2600 X Power",
|
||||
model: models[2 % models.length],
|
||||
code: `${XP_TROPHY_CODE_PREFIX}2600`,
|
||||
},
|
||||
];
|
||||
|
||||
for (const trophy of specialTrophyValues) {
|
||||
await db.insertInto("Trophy").values(trophy).execute();
|
||||
}
|
||||
|
||||
await TrophyRepository.syncSpecialTrophies();
|
||||
}
|
||||
|
||||
async function patrons() {
|
||||
const userIds = (
|
||||
await db
|
||||
|
|
@ -2184,8 +2746,8 @@ async function realVideoCast() {
|
|||
}
|
||||
|
||||
// some copy+paste from placements script
|
||||
function xRankPlacements() {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
async function xRankPlacements() {
|
||||
await db.transaction().execute(async (trx) => {
|
||||
for (const [i, placement] of placements.entries()) {
|
||||
const userId = () => {
|
||||
// admin
|
||||
|
|
@ -2219,6 +2781,8 @@ function xRankPlacements() {
|
|||
.execute();
|
||||
}
|
||||
});
|
||||
|
||||
await XRankPlacementRepository.refreshAllPeakXp();
|
||||
}
|
||||
|
||||
const addUnvalidatedUserSubmittedImage = (url: string, authorId: number) =>
|
||||
|
|
@ -3381,10 +3945,11 @@ function insertTournamentWithId(values: {
|
|||
mapPickingStyle: TournamentMapPickingStyle;
|
||||
settings: string;
|
||||
isFinalized?: DBBoolean;
|
||||
tier?: TournamentTierNumber | null;
|
||||
}) {
|
||||
return sql`
|
||||
insert into "Tournament" ("id", "mapPickingStyle", "settings", "isFinalized")
|
||||
values (${values.id}, ${values.mapPickingStyle}, ${values.settings}, ${values.isFinalized ?? 0})
|
||||
insert into "Tournament" ("id", "mapPickingStyle", "settings", "isFinalized", "tier")
|
||||
values (${values.id}, ${values.mapPickingStyle}, ${values.settings}, ${values.isFinalized ?? 0}, ${values.tier ?? null})
|
||||
`.execute(db);
|
||||
}
|
||||
|
||||
|
|
@ -3399,10 +3964,11 @@ function insertCalendarEventWithId(values: {
|
|||
organizationId?: number | null;
|
||||
avatarImgId?: number | null;
|
||||
tags?: string | null;
|
||||
trophyId?: number | null;
|
||||
}) {
|
||||
return sql`
|
||||
insert into "CalendarEvent" ("id", "name", "description", "discordInviteCode", "bracketUrl", "authorId", "tournamentId", "organizationId", "avatarImgId", "tags")
|
||||
values (${values.id}, ${values.name}, ${values.description}, ${values.discordInviteCode}, ${values.bracketUrl}, ${values.authorId}, ${values.tournamentId ?? null}, ${values.organizationId ?? null}, ${values.avatarImgId ?? null}, ${values.tags ?? null})
|
||||
insert into "CalendarEvent" ("id", "name", "description", "discordInviteCode", "bracketUrl", "authorId", "tournamentId", "organizationId", "avatarImgId", "tags", "trophyId")
|
||||
values (${values.id}, ${values.name}, ${values.description}, ${values.discordInviteCode}, ${values.bracketUrl}, ${values.authorId}, ${values.tournamentId ?? null}, ${values.organizationId ?? null}, ${values.avatarImgId ?? null}, ${values.tags ?? null}, ${values.trophyId ?? null})
|
||||
`.execute(db);
|
||||
}
|
||||
|
||||
|
|
@ -3419,3 +3985,73 @@ function insertTournamentTeamWithId(values: {
|
|||
values (${values.id}, ${values.name}, ${values.createdAt}, ${values.tournamentId}, ${values.inviteCode}, ${values.seed})
|
||||
`.execute(db);
|
||||
}
|
||||
|
||||
function insertTournamentStageWithId(values: {
|
||||
id: number;
|
||||
tournamentId: number;
|
||||
name: string;
|
||||
number: number;
|
||||
type: string;
|
||||
settings: string;
|
||||
}) {
|
||||
return sql`
|
||||
insert into "TournamentStage" ("id", "tournamentId", "name", "number", "type", "settings")
|
||||
values (${values.id}, ${values.tournamentId}, ${values.name}, ${values.number}, ${values.type}, ${values.settings})
|
||||
`.execute(db);
|
||||
}
|
||||
|
||||
function insertTournamentGroupWithId(values: {
|
||||
id: number;
|
||||
stageId: number;
|
||||
number: number;
|
||||
}) {
|
||||
return sql`
|
||||
insert into "TournamentGroup" ("id", "stageId", "number")
|
||||
values (${values.id}, ${values.stageId}, ${values.number})
|
||||
`.execute(db);
|
||||
}
|
||||
|
||||
function insertTournamentRoundWithId(values: {
|
||||
id: number;
|
||||
stageId: number;
|
||||
groupId: number;
|
||||
number: number;
|
||||
maps: string;
|
||||
}) {
|
||||
return sql`
|
||||
insert into "TournamentRound" ("id", "stageId", "groupId", "number", "maps")
|
||||
values (${values.id}, ${values.stageId}, ${values.groupId}, ${values.number}, ${values.maps})
|
||||
`.execute(db);
|
||||
}
|
||||
|
||||
function insertTournamentMatchWithId(values: {
|
||||
id: number;
|
||||
stageId: number;
|
||||
groupId: number;
|
||||
roundId: number;
|
||||
number: number;
|
||||
opponentOne: string;
|
||||
opponentTwo: string;
|
||||
winnerSide: "opponent1" | "opponent2";
|
||||
}) {
|
||||
return sql`
|
||||
insert into "TournamentMatch" ("id", "stageId", "groupId", "roundId", "number", "opponentOne", "opponentTwo", "winnerSide")
|
||||
values (${values.id}, ${values.stageId}, ${values.groupId}, ${values.roundId}, ${values.number}, ${values.opponentOne}, ${values.opponentTwo}, ${values.winnerSide})
|
||||
`.execute(db);
|
||||
}
|
||||
|
||||
function insertTournamentMatchGameResultWithId(values: {
|
||||
id: number;
|
||||
matchId: number;
|
||||
mode: string;
|
||||
number: number;
|
||||
reporterId: number;
|
||||
source: string;
|
||||
stageId: number;
|
||||
winnerTeamId: number;
|
||||
}) {
|
||||
return sql`
|
||||
insert into "TournamentMatchGameResult" ("id", "matchId", "mode", "number", "reporterId", "source", "stageId", "winnerTeamId")
|
||||
values (${values.id}, ${values.matchId}, ${values.mode}, ${values.number}, ${values.reporterId}, ${values.source}, ${values.stageId}, ${values.winnerTeamId})
|
||||
`.execute(db);
|
||||
}
|
||||
|
|
|
|||
5
app/db/seed/trophies.json
Normal file
5
app/db/seed/trophies.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -165,6 +165,51 @@ export interface BadgeOwner {
|
|||
count: number;
|
||||
}
|
||||
|
||||
export interface Trophy {
|
||||
id: GeneratedAlways<number>;
|
||||
name: string;
|
||||
model: string;
|
||||
/** Identifies special trophies, null for regular trophies. */
|
||||
code: Generated<string | null>;
|
||||
organizationId: number | null;
|
||||
creatorId: number | null;
|
||||
managerId: number | null;
|
||||
}
|
||||
|
||||
export interface TrophyOwner {
|
||||
trophyId: number;
|
||||
userId: number;
|
||||
tournamentId: number;
|
||||
tier: number | null;
|
||||
}
|
||||
|
||||
export interface SpecialTrophyOwner {
|
||||
trophyId: number;
|
||||
userId: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface PendingTrophy {
|
||||
id: GeneratedAlways<number>;
|
||||
name: string;
|
||||
model: string;
|
||||
description: string;
|
||||
organizationId: number | null;
|
||||
submitterUserId: number;
|
||||
createdAt: number;
|
||||
declineReason: string | null;
|
||||
declinedAt: number | null;
|
||||
declinedByUserId: number | null;
|
||||
targetTrophyId: number | null;
|
||||
managerId: number | null;
|
||||
}
|
||||
|
||||
export interface PendingTrophyApproval {
|
||||
pendingTrophyId: number;
|
||||
userId: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface Build {
|
||||
clothesGearSplId: number | null;
|
||||
description: string | null;
|
||||
|
|
@ -222,6 +267,7 @@ export interface CalendarEvent {
|
|||
tournamentId: number | null;
|
||||
organizationId: number | null;
|
||||
avatarImgId: number | null;
|
||||
trophyId: number | null;
|
||||
}
|
||||
|
||||
export interface CalendarEventBadge {
|
||||
|
|
@ -807,6 +853,8 @@ export interface User {
|
|||
discordUniqueName: string | null;
|
||||
/** User's favorite badges they want to show on the front page of the badge display. Index = 0 big badge. */
|
||||
favoriteBadgeIds: JSONColumnTypeNullable<number[]>;
|
||||
favoriteTrophyIds: JSONColumnTypeNullable<number[]>;
|
||||
hiddenTrophyIds: JSONColumnTypeNullable<number[]>;
|
||||
id: GeneratedAlways<number>;
|
||||
inGameName: string | null;
|
||||
isArtist: Generated<DBBoolean>;
|
||||
|
|
@ -1245,6 +1293,11 @@ export interface DB {
|
|||
TournamentOrganizationBannedUser: TournamentOrganizationBannedUser;
|
||||
TournamentStreamer: TournamentStreamer;
|
||||
TournamentMatchVod: TournamentMatchVod;
|
||||
Trophy: Trophy;
|
||||
TrophyOwner: TrophyOwner;
|
||||
SpecialTrophyOwner: SpecialTrophyOwner;
|
||||
PendingTrophy: PendingTrophy;
|
||||
PendingTrophyApproval: PendingTrophyApproval;
|
||||
TrustRelationship: TrustRelationship;
|
||||
Friendship: Friendship;
|
||||
FriendRequest: FriendRequest;
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Trash } from "lucide-react";
|
|||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "~/components/Badge";
|
||||
import { DotPagination } from "~/components/DotPagination";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { BADGE } from "~/features/badges/badges-constants";
|
||||
|
|
@ -108,43 +109,15 @@ export function BadgeDisplay({
|
|||
</div>
|
||||
) : null}
|
||||
{!everythingVisible ? (
|
||||
<BadgePagination
|
||||
<DotPagination
|
||||
pagesCount={pagesCount}
|
||||
currentPage={currentPage}
|
||||
setPage={setPage}
|
||||
ariaLabelPrefix="Badges"
|
||||
data-testid="badge-pagination-button"
|
||||
className={styles.pagination}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BadgePaginationProps {
|
||||
pagesCount: number;
|
||||
currentPage: number;
|
||||
setPage: (page: number) => void;
|
||||
}
|
||||
|
||||
function BadgePagination({
|
||||
pagesCount,
|
||||
currentPage,
|
||||
setPage,
|
||||
}: BadgePaginationProps) {
|
||||
return (
|
||||
<div className={styles.pagination}>
|
||||
{Array.from({ length: pagesCount }, (_, i) => (
|
||||
<SendouButton
|
||||
key={i}
|
||||
variant="minimal"
|
||||
aria-label={`Badges page ${i + 1}`}
|
||||
onPress={() => setPage(i + 1)}
|
||||
className={clsx(styles.paginationButton, {
|
||||
[styles.paginationButtonActive]: currentPage === i + 1,
|
||||
})}
|
||||
data-testid="badge-pagination-button"
|
||||
>
|
||||
{i + 1}
|
||||
</SendouButton>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ export default function BadgeDetailsPage() {
|
|||
Edit
|
||||
</LinkButton>
|
||||
) : null}
|
||||
<div className={styles.ownersContainer}>
|
||||
<div className={clsx(styles.ownersContainer, "scrollbar")}>
|
||||
<ul className={styles.owners}>
|
||||
{data.badge.owners.map((owner) => (
|
||||
<li key={owner.id}>
|
||||
|
|
|
|||
|
|
@ -82,6 +82,15 @@ const withBadgePrizes = (eb: ExpressionBuilder<DB, "CalendarEvent">) => {
|
|||
).as("badgePrizes");
|
||||
};
|
||||
|
||||
const withTrophy = (eb: ExpressionBuilder<DB, "CalendarEvent">) => {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom("Trophy")
|
||||
.select(["Trophy.id", "Trophy.name", "Trophy.model"])
|
||||
.whereRef("Trophy.id", "=", "CalendarEvent.trophyId"),
|
||||
).as("trophy");
|
||||
};
|
||||
|
||||
function tournamentOrganization(organizationId: Expression<number | null>) {
|
||||
return jsonObjectFrom(
|
||||
db
|
||||
|
|
@ -200,6 +209,12 @@ function findAllBetweenTwoTimestampsQuery({
|
|||
)
|
||||
.orderBy("Badge.id", "asc"),
|
||||
).as("badges"),
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom("Trophy")
|
||||
.select(["Trophy.model"])
|
||||
.whereRef("Trophy.id", "=", "CalendarEvent.trophyId"),
|
||||
).as("trophy"),
|
||||
])
|
||||
.where("CalendarEvent.hidden", "=", 0)
|
||||
.where(
|
||||
|
|
@ -256,6 +271,7 @@ function findAllBetweenTwoTimestampsMapped(
|
|||
? modesIncluded(row.mapPickingStyle, row.toSetMapPool)
|
||||
: null,
|
||||
badges: row.badges,
|
||||
trophy: row.trophy,
|
||||
logoUrl: row.logoUrl,
|
||||
startsAt: row.normalizedStartsAt,
|
||||
isRanked: row.tournamentSettings
|
||||
|
|
@ -289,10 +305,12 @@ export async function findById(
|
|||
includeMapPool = false,
|
||||
includeTieBreakerMapPool = false,
|
||||
includeBadgePrizes = false,
|
||||
includeTrophy = false,
|
||||
}: {
|
||||
includeMapPool?: boolean;
|
||||
includeTieBreakerMapPool?: boolean;
|
||||
includeBadgePrizes?: boolean;
|
||||
includeTrophy?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const [firstRow, ...rest] = await db
|
||||
|
|
@ -300,6 +318,7 @@ export async function findById(
|
|||
.$if(includeMapPool, (qb) => qb.select(withMapPool))
|
||||
.$if(includeTieBreakerMapPool, (qb) => qb.select(withTieBreakerMapPool))
|
||||
.$if(includeBadgePrizes, (qb) => qb.select(withBadgePrizes))
|
||||
.$if(includeTrophy, (qb) => qb.select(withTrophy))
|
||||
.innerJoin(
|
||||
"CalendarEventDate",
|
||||
"CalendarEvent.id",
|
||||
|
|
@ -407,6 +426,7 @@ type CreateArgs = Pick<
|
|||
> & {
|
||||
startTimes: Array<Tables["CalendarEventDate"]["startsAt"]>;
|
||||
badges: Array<Tables["CalendarEventBadge"]["badgeId"]>;
|
||||
trophyId?: Tables["CalendarEvent"]["trophyId"];
|
||||
mapPoolMaps?: Array<Pick<Tables["MapPoolMap"], "mode" | "stageId">>;
|
||||
isFullTournament: boolean;
|
||||
mapPickingStyle: Tables["Tournament"]["mapPickingStyle"];
|
||||
|
|
@ -521,6 +541,7 @@ export async function insert(args: CreateArgs) {
|
|||
organizationId: args.organizationId,
|
||||
hidden: args.parentTournamentId || args.isTest || args.isDraft ? 1 : 0,
|
||||
tournamentId,
|
||||
trophyId: args.trophyId ?? null,
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
|
@ -586,6 +607,7 @@ export async function update(args: UpdateArgs) {
|
|||
bracketUrl: args.bracketUrl,
|
||||
avatarImgId: args.avatarImgId ?? avatarImgId,
|
||||
organizationId: args.organizationId,
|
||||
trophyId: args.trophyId ?? null,
|
||||
})
|
||||
.where("id", "=", args.eventId)
|
||||
.returning("tournamentId")
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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" &&
|
||||
|
|
|
|||
5
app/features/calendar/calendar-new.module.css
Normal file
5
app/features/calendar/calendar-new.module.css
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
.trophyPreview {
|
||||
background-color: var(--color-bg-high);
|
||||
border: var(--border-style);
|
||||
border-radius: var(--radius-field);
|
||||
}
|
||||
|
|
@ -40,6 +40,7 @@ export interface CalendarEvent extends CommonEvent {
|
|||
badges: Array<
|
||||
Pick<Tables["Badge"], "id" | "code" | "displayName" | "hue">
|
||||
> | null;
|
||||
trophy: Pick<Tables["Trophy"], "model"> | null;
|
||||
}
|
||||
|
||||
export interface ShowcaseCalendarEvent extends CommonEvent {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import clsx from "clsx";
|
||||
import { ShieldMinus, Trophy, Users } from "lucide-react";
|
||||
import { ShieldMinus, Trophy as TrophyIcon, Users } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
|
|
@ -9,6 +9,7 @@ import { Image, ModeImage } from "~/components/Image";
|
|||
import { LocaleTime } from "~/components/LocaleTime";
|
||||
import { TierPill } from "~/components/TierPill";
|
||||
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
|
||||
import { Trophy } from "~/features/trophies/components/Trophy";
|
||||
import { useFormatDistanceToNow } from "~/hooks/intl/useFormatDistanceToNow";
|
||||
import { useHydrated } from "~/hooks/useHydrated";
|
||||
import { useSpoilerFree } from "~/hooks/useSpoilerFree";
|
||||
|
|
@ -142,11 +143,16 @@ export function TournamentCard({
|
|||
>
|
||||
{tournament.isRanked ? (
|
||||
<div className={clsx(styles.pill, styles.pillRanked)}>
|
||||
<Trophy />
|
||||
<TrophyIcon />
|
||||
</div>
|
||||
) : null}
|
||||
{isCalendar && tournament.badges && tournament.badges.length > 0 ? (
|
||||
<BadgePrizesPill badges={tournament.badges} />
|
||||
{isCalendar &&
|
||||
(tournament.trophy ||
|
||||
(tournament.badges && tournament.badges.length > 0)) ? (
|
||||
<PrizesPill
|
||||
badges={tournament.badges}
|
||||
trophy={tournament.trophy?.model}
|
||||
/>
|
||||
) : null}
|
||||
{isHostedOnSendouInk ? (
|
||||
<div className={styles.teamCount}>
|
||||
|
|
@ -292,10 +298,12 @@ function ModesPill({ modes }: { modes: NonNullable<CalendarEvent["modes"]> }) {
|
|||
);
|
||||
}
|
||||
|
||||
function BadgePrizesPill({
|
||||
function PrizesPill({
|
||||
badges,
|
||||
trophy,
|
||||
}: {
|
||||
badges: NonNullable<CalendarEvent["badges"]>;
|
||||
badges: CalendarEvent["badges"];
|
||||
trophy?: string;
|
||||
}) {
|
||||
return (
|
||||
<SendouPopover
|
||||
|
|
@ -307,18 +315,22 @@ function BadgePrizesPill({
|
|||
>
|
||||
<Image
|
||||
size={16}
|
||||
path={navIconUrl("badges")}
|
||||
path={trophy ? navIconUrl("trophies") : navIconUrl("badges")}
|
||||
alt="Badge prizes"
|
||||
className={styles.badgeNavIcon}
|
||||
/>
|
||||
</SendouButton>
|
||||
}
|
||||
>
|
||||
<BadgeDisplay
|
||||
badges={badges}
|
||||
showText={false}
|
||||
className={styles.badgeDisplay}
|
||||
/>
|
||||
{trophy ? (
|
||||
<Trophy model={trophy} className={styles.trophyPreview} />
|
||||
) : badges ? (
|
||||
<BadgeDisplay
|
||||
badges={badges}
|
||||
showText={false}
|
||||
className={styles.badgeDisplay}
|
||||
/>
|
||||
) : null}
|
||||
</SendouPopover>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ function makeEvent(
|
|||
type: "calendar",
|
||||
normalizedTeamCount: 0,
|
||||
badges: [],
|
||||
trophy: null,
|
||||
logoUrl: null,
|
||||
name: "",
|
||||
url: "",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { Trash } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { MetaFunction } from "react-router";
|
||||
|
|
@ -13,6 +14,7 @@ import { SubmitButton } from "~/components/SubmitButton";
|
|||
import type { Tables } from "~/db/tables";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import * as Progression from "~/features/tournament-bracket/core/Progression";
|
||||
import { Trophy } from "~/features/trophies/components/Trophy";
|
||||
import { type CustomFieldRenderProps, FormField } from "~/form/FormField";
|
||||
import { existingImage } from "~/form/image-field";
|
||||
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
|
||||
|
|
@ -26,6 +28,7 @@ import type { SendouRouteHandle } from "~/utils/remix.server";
|
|||
import { CREATING_TOURNAMENT_DOC_LINK, FAQ_PAGE } from "~/utils/urls";
|
||||
import { action } from "../actions/calendar.new.server";
|
||||
import type { RegClosesAtOption } from "../calendar-constants";
|
||||
import styles from "../calendar-new.module.css";
|
||||
import { calendarNewBaseSchema } from "../calendar-new-schemas";
|
||||
import { datesToRegClosesAt } from "../calendar-utils";
|
||||
import { BracketProgressionSelector } from "../components/BracketProgressionSelector";
|
||||
|
|
@ -194,6 +197,7 @@ function useDefaultValues() {
|
|||
discordInviteCode: baseEvent?.discordInviteCode ?? "",
|
||||
tags: baseEvent?.tags ?? [],
|
||||
badges: baseEvent?.badgePrizes?.map((b) => b.id) ?? [],
|
||||
trophyId: baseEvent?.trophy?.id ?? null,
|
||||
avatarImgId: existingImage(
|
||||
baseEvent?.avatarImgId,
|
||||
baseEvent?.tournament?.ctx.logoUrl,
|
||||
|
|
@ -291,6 +295,7 @@ function CalendarNewFields() {
|
|||
{data.badgeOptions.length > 0 ? (
|
||||
<FormField name="badges" options={data.badgeOptions} />
|
||||
) : null}
|
||||
{isTournament ? <TrophyField /> : null}
|
||||
{isTournament ? <FormField name="avatarImgId" /> : null}
|
||||
{isTournament ? (
|
||||
<>
|
||||
|
|
@ -331,6 +336,97 @@ function DescriptionField({ isTournament }: { isTournament: boolean }) {
|
|||
);
|
||||
}
|
||||
|
||||
function TrophyField() {
|
||||
const { t } = useTranslation("calendar");
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const { values, setValue } = useFormFieldContext();
|
||||
const id = React.useId();
|
||||
|
||||
const organizationId = values.organizationId
|
||||
? Number(values.organizationId)
|
||||
: null;
|
||||
const trophyId = typeof values.trophyId === "number" ? values.trophyId : null;
|
||||
const badgeCount = (values.badges as number[]).length;
|
||||
|
||||
// clear the trophy when the selected organization or badges make it invalid
|
||||
React.useEffect(() => {
|
||||
if (!trophyId) return;
|
||||
const trophyStillValid =
|
||||
badgeCount === 0 &&
|
||||
data.trophies.some(
|
||||
(trophy) =>
|
||||
trophy.id === trophyId && trophy.organizationId === organizationId,
|
||||
);
|
||||
if (!trophyStillValid) {
|
||||
setValue("trophyId", null);
|
||||
}
|
||||
}, [trophyId, badgeCount, organizationId, data.trophies, setValue]);
|
||||
|
||||
const availableTrophies = organizationId
|
||||
? data.trophies.filter((trophy) => trophy.organizationId === organizationId)
|
||||
: [];
|
||||
|
||||
if (availableTrophies.length === 0 && trophyId === null) return null;
|
||||
|
||||
const selectedTrophy = trophyId
|
||||
? data.trophies.find((trophy) => trophy.id === trophyId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<FormField name="trophyId">
|
||||
{({ onChange }: CustomFieldRenderProps) => {
|
||||
const handleChange = (newTrophyId: number | null) => {
|
||||
onChange(newTrophyId);
|
||||
if (newTrophyId) {
|
||||
setValue("badges", []);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
<div>
|
||||
<label htmlFor={id}>{t("forms.trophy")}</label>
|
||||
<select
|
||||
id={id}
|
||||
value={trophyId ?? ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
handleChange(value === "" ? null : Number(value));
|
||||
}}
|
||||
>
|
||||
<option value="">{t("forms.trophy.placeholder")}</option>
|
||||
{availableTrophies.map((trophy) => (
|
||||
<option key={trophy.id} value={trophy.id}>
|
||||
{trophy.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{selectedTrophy ? (
|
||||
<div className="stack md items-center">
|
||||
<Trophy
|
||||
model={selectedTrophy.model}
|
||||
className={styles.trophyPreview}
|
||||
/>
|
||||
<div className="stack horizontal md items-center">
|
||||
<span>{selectedTrophy.name}</span>
|
||||
<SendouButton
|
||||
className="ml-auto"
|
||||
onPress={() => handleChange(null)}
|
||||
icon={<Trash />}
|
||||
variant="minimal-destructive"
|
||||
aria-label="Remove trophy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberCountFields() {
|
||||
const { values } = useFormFieldContext();
|
||||
|
||||
|
|
|
|||
|
|
@ -23,3 +23,15 @@
|
|||
.componentContent {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.trophyExample {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.trophyExampleSmall {
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
.trophyExampleLarge {
|
||||
width: 200px;
|
||||
}
|
||||
|
|
|
|||
2
app/features/components-showcase/example-trophy-model.ts
Normal file
2
app/features/components-showcase/example-trophy-model.ts
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -1,4 +1,5 @@
|
|||
import { parseDate } from "@internationalized/date";
|
||||
import clsx from "clsx";
|
||||
import { Check, Plus, Search, SquarePen, Trash } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Ability } from "~/components/Ability";
|
||||
|
|
@ -48,7 +49,12 @@ import { StageSelect } from "~/components/StageSelect";
|
|||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { SubNav, SubNavLink } from "~/components/SubNav";
|
||||
import { Table } from "~/components/Table";
|
||||
import { TierPill } from "~/components/TierPill";
|
||||
import { WeaponSelect } from "~/components/WeaponSelect";
|
||||
import {
|
||||
Trophy,
|
||||
TrophyContextProvider,
|
||||
} from "~/features/trophies/components/Trophy";
|
||||
import { UserCard } from "~/features/user-card/components/UserCard";
|
||||
import type { UserCardData } from "~/features/user-card/user-card-types";
|
||||
import type { CustomFieldRenderProps } from "~/form/FormField";
|
||||
|
|
@ -56,6 +62,7 @@ import { SendouForm } from "~/form/SendouForm";
|
|||
import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import styles from "../components-showcase.module.css";
|
||||
import { EXAMPLE_TROPHY_MODEL } from "../example-trophy-model";
|
||||
import { formFieldsShowcaseSchema } from "../form-examples-schema";
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
|
|
@ -102,6 +109,8 @@ export const SECTIONS = [
|
|||
{ title: "Flags", id: "flags", component: FlagSection },
|
||||
{ title: "Placements", id: "placements", component: PlacementSection },
|
||||
{ title: "Badges", id: "badges", component: BadgeSection },
|
||||
{ title: "Trophies", id: "trophies", component: TrophySection },
|
||||
{ title: "Tier Pills", id: "tier-pills", component: TierPillSection },
|
||||
{ title: "Game Selects", id: "game-selects", component: GameSelectSection },
|
||||
{ title: "Form Fields", id: "form-fields", component: FormFieldsSection },
|
||||
{ title: "Miscellaneous", id: "miscellaneous", component: MiscSection },
|
||||
|
|
@ -2048,6 +2057,115 @@ function BadgeSection({ id }: { id: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
function TrophySection({ id }: { id: string }) {
|
||||
return (
|
||||
<Section>
|
||||
<SectionTitle id={id}>Trophies</SectionTitle>
|
||||
|
||||
<TrophyContextProvider>
|
||||
<div className="stack md">
|
||||
<ComponentRow label="Interactive (drag to rotate)">
|
||||
<Trophy
|
||||
tile
|
||||
model={EXAMPLE_TROPHY_MODEL}
|
||||
className={styles.trophyExample}
|
||||
/>
|
||||
</ComponentRow>
|
||||
|
||||
<ComponentRow label="Preview (static)">
|
||||
<Trophy
|
||||
tile
|
||||
model={EXAMPLE_TROPHY_MODEL}
|
||||
className={styles.trophyExample}
|
||||
preview
|
||||
/>
|
||||
</ComponentRow>
|
||||
|
||||
<ComponentRow label="With Tier">
|
||||
<div className="stack horizontal sm flex-wrap">
|
||||
{([1, 4, 9] as const).map((tier) => (
|
||||
<Trophy
|
||||
key={tier}
|
||||
tile
|
||||
model={EXAMPLE_TROPHY_MODEL}
|
||||
className={styles.trophyExample}
|
||||
tier={tier}
|
||||
preview
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ComponentRow>
|
||||
|
||||
<ComponentRow label="Tentative Tier">
|
||||
<Trophy
|
||||
tile
|
||||
model={EXAMPLE_TROPHY_MODEL}
|
||||
className={styles.trophyExample}
|
||||
tentativeTier={2}
|
||||
preview
|
||||
/>
|
||||
</ComponentRow>
|
||||
|
||||
<ComponentRow label="Different Sizes">
|
||||
<div className="stack horizontal sm items-end flex-wrap">
|
||||
<Trophy
|
||||
tile
|
||||
model={EXAMPLE_TROPHY_MODEL}
|
||||
className={clsx(
|
||||
styles.trophyExample,
|
||||
styles.trophyExampleSmall,
|
||||
)}
|
||||
preview
|
||||
/>
|
||||
<Trophy
|
||||
tile
|
||||
model={EXAMPLE_TROPHY_MODEL}
|
||||
className={styles.trophyExample}
|
||||
preview
|
||||
/>
|
||||
<Trophy
|
||||
tile
|
||||
model={EXAMPLE_TROPHY_MODEL}
|
||||
className={clsx(
|
||||
styles.trophyExample,
|
||||
styles.trophyExampleLarge,
|
||||
)}
|
||||
preview
|
||||
/>
|
||||
</div>
|
||||
</ComponentRow>
|
||||
</div>
|
||||
</TrophyContextProvider>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function TierPillSection({ id }: { id: string }) {
|
||||
return (
|
||||
<Section>
|
||||
<SectionTitle id={id}>Tier Pills</SectionTitle>
|
||||
|
||||
<div className="stack md">
|
||||
<ComponentRow label="All Tiers">
|
||||
<div className="stack horizontal sm flex-wrap">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((tier) => (
|
||||
<TierPill key={tier} tier={tier} />
|
||||
))}
|
||||
</div>
|
||||
</ComponentRow>
|
||||
|
||||
<ComponentRow label="Tentative">
|
||||
<div className="stack horizontal sm flex-wrap">
|
||||
{[1, 4, 9].map((tier) => (
|
||||
<TierPill key={tier} tier={tier} isTentative />
|
||||
))}
|
||||
</div>
|
||||
</ComponentRow>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function GameSelectSection({ id }: { id: string }) {
|
||||
const [selectedWeapon, setSelectedWeapon] = useState<MainWeaponId | null>(
|
||||
null,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { PWAInstallBanner } from "~/features/front-page/components/PWAInstallBan
|
|||
import { SplatoonRotations } from "~/features/front-page/components/SplatoonRotations";
|
||||
import type * as Changelog from "~/features/front-page/core/Changelog.server";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import { canAccessTrophies } from "~/features/trophies/trophies-utils";
|
||||
import styles from "~/styles/front.module.css";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
|
|
@ -263,7 +264,7 @@ function ResultHighlights() {
|
|||
>
|
||||
{t("front:showcase.results")}
|
||||
</h2>
|
||||
<div className={styles.tournamentCardsSpacer}>
|
||||
<div className={clsx(styles.tournamentCardsSpacer, "scrollbar")}>
|
||||
{data.tournaments.results.map((tournament) => (
|
||||
<TournamentCard key={tournament.id} tournament={tournament} />
|
||||
))}
|
||||
|
|
@ -316,9 +317,12 @@ const DISCOVER_EXCLUDED_ITEMS = new Set(["settings", "luti"]);
|
|||
function DiscoverFeatures() {
|
||||
const { t } = useTranslation(["front", "common"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const user = useUser();
|
||||
|
||||
const filteredNavItems = navItems.filter(
|
||||
(item) => !DISCOVER_EXCLUDED_ITEMS.has(item.name),
|
||||
(item) =>
|
||||
!DISCOVER_EXCLUDED_ITEMS.has(item.name) &&
|
||||
(item.name !== "trophies" || canAccessTrophies(user)),
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ const NOTIFICATION_URGENCY: Record<Notification["type"], Urgency> = {
|
|||
TO_LIKE_ACCEPTED: "high",
|
||||
BADGE_ADDED: "normal",
|
||||
BADGE_MANAGER_ADDED: "normal",
|
||||
TROPHY_SUBMITTED: "normal",
|
||||
TROPHY_SUBMISSION_ACCEPTED: "normal",
|
||||
TROPHY_SUBMISSION_DECLINED: "normal",
|
||||
PLUS_VOTING_STARTED: "normal",
|
||||
PLUS_SUGGESTION_ADDED: "normal",
|
||||
TAGGED_TO_ART: "normal",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import type {
|
|||
DragStartEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
import JSONCrush from "jsoncrush";
|
||||
import * as React from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { z } from "zod";
|
||||
|
|
@ -31,7 +30,12 @@ import {
|
|||
tierListItemTypeSchema,
|
||||
tierListStateSerializedSchema,
|
||||
} from "../tier-list-maker-schemas";
|
||||
import { addItemToTier, getNextNthForItem } from "../tier-list-maker-utils";
|
||||
import {
|
||||
addItemToTier,
|
||||
compress,
|
||||
decompress,
|
||||
getNextNthForItem,
|
||||
} from "../tier-list-maker-utils";
|
||||
|
||||
export type TierListPlacementMode = "track" | "click";
|
||||
|
||||
|
|
@ -517,11 +521,10 @@ function useSearchParamTiersState() {
|
|||
|
||||
try {
|
||||
if (param) {
|
||||
const uncrushed = JSONCrush.uncrush(param);
|
||||
const decompressed = decompress<unknown>(param);
|
||||
if (decompressed === null) throw new Error("Failed to decompress");
|
||||
|
||||
const parsed = tierListStateSerializedSchema.parse(
|
||||
JSON.parse(uncrushed),
|
||||
);
|
||||
const parsed = tierListStateSerializedSchema.parse(decompressed);
|
||||
|
||||
return {
|
||||
tiers: parsed.tiers,
|
||||
|
|
@ -539,12 +542,14 @@ function useSearchParamTiersState() {
|
|||
const persistTiersStateToParams = (state: TierListState) => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
const serializedState = JSON.stringify({
|
||||
tiers: state.tiers,
|
||||
tierItems: Array.from(state.tierItems.entries()),
|
||||
});
|
||||
searchParams.set(
|
||||
TIER_SEARCH_PARAM_NAME,
|
||||
compress({
|
||||
tiers: state.tiers,
|
||||
tierItems: Array.from(state.tierItems.entries()),
|
||||
}),
|
||||
);
|
||||
|
||||
searchParams.set(TIER_SEARCH_PARAM_NAME, JSONCrush.crush(serializedState));
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { compressToBase64, decompressFromBase64 } from "~/utils/compression";
|
||||
import type { TierListItem, TierListState } from "./tier-list-maker-schemas";
|
||||
|
||||
export function tierListItemId(item: TierListItem) {
|
||||
|
|
@ -50,3 +51,18 @@ export function getNextNthForItem(
|
|||
}, 0) + 1
|
||||
);
|
||||
}
|
||||
|
||||
export function compress<T>(obj: T) {
|
||||
return compressToBase64(JSON.stringify(obj), { urlSafe: true });
|
||||
}
|
||||
|
||||
export function decompress<T>(compressed: string) {
|
||||
const json = decompressFromBase64(compressed);
|
||||
if (json === null) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(json) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,8 +10,15 @@ import { FormMessage } from "~/components/FormMessage";
|
|||
import { Placement } from "~/components/Placement";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { useTournament } from "~/features/tournament/routes/to.$id";
|
||||
import type { TournamentBadgeReceivers } from "~/features/tournament-bracket/tournament-bracket-schemas.server";
|
||||
import { validateBadgeReceivers } from "~/features/tournament-bracket/tournament-bracket-utils";
|
||||
import type {
|
||||
TournamentBadgeReceivers,
|
||||
TournamentTrophyReceiver,
|
||||
} from "~/features/tournament-bracket/tournament-bracket-schemas.server";
|
||||
import {
|
||||
validateBadgeReceivers,
|
||||
validateTrophyReceiver,
|
||||
} from "~/features/tournament-bracket/tournament-bracket-utils";
|
||||
import { Trophy } from "~/features/trophies/components/Trophy";
|
||||
import { ParticipationPill } from "~/features/user-page/components/ParticipationPill";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { action } from "../actions/to.$id.brackets.finalize.server";
|
||||
|
|
@ -26,19 +33,48 @@ export default function TournamentFinalizePage() {
|
|||
const data = useLoaderData<typeof loader>();
|
||||
const { t } = useTranslation(["tournament"]);
|
||||
const location = useLocation();
|
||||
const [isAssignLaterSelected, setIsAssignLaterSelected] =
|
||||
const tournament = useTournament();
|
||||
const firstPlaceStanding = data.standings.find(
|
||||
(standing) => standing.placement === 1,
|
||||
);
|
||||
const trophyDefaultUserIds =
|
||||
data.trophy && firstPlaceStanding
|
||||
? tournament.minMembersPerTeam === firstPlaceStanding.members.length
|
||||
? firstPlaceStanding.members.map((m) => m.userId)
|
||||
: []
|
||||
: [];
|
||||
|
||||
const [isAssignBadgesLaterSelected, setIsAssignBadgesLaterSelected] =
|
||||
React.useState(false);
|
||||
const [badgeReceivers, setBadgeReceivers] =
|
||||
React.useState<TournamentBadgeReceivers>([]);
|
||||
const [trophyReceiverUserIds, setTrophyReceiverUserIds] =
|
||||
React.useState<Array<number>>(trophyDefaultUserIds);
|
||||
|
||||
const bracketUrl = location.pathname.replace(/\/finalize$/, "");
|
||||
|
||||
const tournamentHasBadges = data.badges.length > 0;
|
||||
const tournamentHasTrophy = Boolean(data.trophy);
|
||||
|
||||
const badgesError = !isAssignLaterSelected
|
||||
? validateBadgeReceivers({ badgeReceivers, badges: data.badges })
|
||||
const trophyReceiver: TournamentTrophyReceiver | null =
|
||||
data.trophy && firstPlaceStanding
|
||||
? { trophyId: data.trophy.id, userIds: trophyReceiverUserIds }
|
||||
: null;
|
||||
|
||||
const badgesError =
|
||||
!isAssignBadgesLaterSelected && !tournamentHasTrophy
|
||||
? validateBadgeReceivers({ badgeReceivers, badges: data.badges })
|
||||
: null;
|
||||
|
||||
const trophyError = tournamentHasTrophy
|
||||
? validateTrophyReceiver({
|
||||
trophyReceiver,
|
||||
trophy: data.trophy ?? null,
|
||||
})
|
||||
: null;
|
||||
|
||||
const error = badgesError ?? trophyError;
|
||||
|
||||
return (
|
||||
<SendouDialog
|
||||
isOpen
|
||||
|
|
@ -46,19 +82,37 @@ export default function TournamentFinalizePage() {
|
|||
heading={t("tournament:actions.finalize")}
|
||||
>
|
||||
<FinalizeForm
|
||||
error={badgesError}
|
||||
isAssigningBadges={!isAssignLaterSelected && tournamentHasBadges}
|
||||
error={error}
|
||||
isAssigningBadges={
|
||||
!isAssignBadgesLaterSelected &&
|
||||
tournamentHasBadges &&
|
||||
!tournamentHasTrophy
|
||||
}
|
||||
>
|
||||
{tournamentHasBadges ? (
|
||||
{tournamentHasTrophy && data.trophy && firstPlaceStanding ? (
|
||||
<>
|
||||
<input
|
||||
type="hidden"
|
||||
name="trophyReceiver"
|
||||
value={JSON.stringify(trophyReceiver)}
|
||||
/>
|
||||
<NewTrophyReceiversSelector
|
||||
trophy={data.trophy}
|
||||
firstPlaceStanding={firstPlaceStanding}
|
||||
trophyReceiverUserIds={trophyReceiverUserIds}
|
||||
setTrophyReceiverUserIds={setTrophyReceiverUserIds}
|
||||
/>
|
||||
</>
|
||||
) : tournamentHasBadges ? (
|
||||
<>
|
||||
<SendouSwitch
|
||||
isSelected={isAssignLaterSelected}
|
||||
onChange={setIsAssignLaterSelected}
|
||||
isSelected={isAssignBadgesLaterSelected}
|
||||
onChange={setIsAssignBadgesLaterSelected}
|
||||
data-testid="assign-badges-later-switch"
|
||||
>
|
||||
{t("tournament:actions.finalize.assignBadgesLater")}
|
||||
</SendouSwitch>
|
||||
{!isAssignLaterSelected ? (
|
||||
{!isAssignBadgesLaterSelected ? (
|
||||
<>
|
||||
<input
|
||||
type="hidden"
|
||||
|
|
@ -86,7 +140,9 @@ function FinalizeForm({
|
|||
isAssigningBadges,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
error: ReturnType<typeof validateBadgeReceivers>;
|
||||
error:
|
||||
| ReturnType<typeof validateBadgeReceivers>
|
||||
| ReturnType<typeof validateTrophyReceiver>;
|
||||
isAssigningBadges: boolean;
|
||||
}) {
|
||||
const fetcher = useFetcher();
|
||||
|
|
@ -248,3 +304,52 @@ function NewBadgeReceiversSelector({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewTrophyReceiversSelector({
|
||||
trophy,
|
||||
firstPlaceStanding,
|
||||
trophyReceiverUserIds,
|
||||
setTrophyReceiverUserIds,
|
||||
}: {
|
||||
trophy: NonNullable<FinalizeTournamentLoaderData["trophy"]>;
|
||||
firstPlaceStanding: FinalizeTournamentLoaderData["standings"][number];
|
||||
trophyReceiverUserIds: Array<number>;
|
||||
setTrophyReceiverUserIds: (userIds: Array<number>) => void;
|
||||
}) {
|
||||
const handleReceiverSelected = (userId: number) => (isSelected: boolean) => {
|
||||
if (isSelected) {
|
||||
setTrophyReceiverUserIds([...trophyReceiverUserIds, userId]);
|
||||
} else {
|
||||
setTrophyReceiverUserIds(
|
||||
trophyReceiverUserIds.filter((id) => id !== userId),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
<Trophy model={trophy.model} />
|
||||
<Divider />
|
||||
{firstPlaceStanding.members.map((member, i) => {
|
||||
return (
|
||||
<div key={member.userId} className="stack sm">
|
||||
<div className="stack horizontal items-center">
|
||||
<SendouSwitch
|
||||
isSelected={trophyReceiverUserIds.includes(member.userId)}
|
||||
onChange={handleReceiverSelected(member.userId)}
|
||||
/>
|
||||
<Avatar user={member} size="xxs" className="mr-2" />
|
||||
{member.username}
|
||||
</div>
|
||||
<div className="stack horizontal sm items-end">
|
||||
<ParticipationPill setResults={member.setResults} />
|
||||
</div>
|
||||
{i !== firstPlaceStanding.members.length - 1 ? (
|
||||
<Divider className="mt-3" />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -183,6 +183,14 @@ const badgeReceivers = z.array(
|
|||
}),
|
||||
);
|
||||
|
||||
export type TournamentTrophyReceiver = z.infer<typeof trophyReceiver>;
|
||||
|
||||
const trophyReceiver = z.object({
|
||||
trophyId: id,
|
||||
userIds: z.array(id).min(1).max(50),
|
||||
});
|
||||
|
||||
export const finalizeTournamentActionSchema = z.object({
|
||||
badgeReceivers: z.preprocess(safeJSONParse, badgeReceivers.nullish()),
|
||||
trophyReceiver: z.preprocess(safeJSONParse, trophyReceiver.nullish()),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@ import {
|
|||
SquarePen,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { MetaFunction } from "react-router";
|
||||
import { Link, useLoaderData, useSearchParams } from "react-router";
|
||||
import { Link, useFetcher, useLoaderData, useSearchParams } from "react-router";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { Divider } from "~/components/Divider";
|
||||
import { LinkButton, SendouButton } from "~/components/elements/Button";
|
||||
|
|
@ -29,6 +30,16 @@ import { TierPill } from "~/components/TierPill";
|
|||
import { useUser } from "~/features/auth/core/user";
|
||||
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
|
||||
import { BannedUsersList } from "~/features/tournament-organization/components/BannedPlayersList";
|
||||
import {
|
||||
Trophy,
|
||||
TrophyContextProvider,
|
||||
TrophyGrid,
|
||||
TrophyPlaceholder,
|
||||
} from "~/features/trophies/components/Trophy";
|
||||
import { TrophyShowcaseModal } from "~/features/trophies/components/TrophyShowcase";
|
||||
import { TrophyTournamentHistory } from "~/features/trophies/components/TrophyTournamentHistory";
|
||||
import type { TrophyTournamentsLoaderData } from "~/features/trophies/routes/trophies.$id.tournaments";
|
||||
import { useProgressiveRender } from "~/features/trophies/trophies-utils";
|
||||
import { SendouForm } from "~/form/SendouForm";
|
||||
import { useHasPermission, useHasRole } from "~/modules/permissions/hooks";
|
||||
import { databaseTimestampNow, databaseTimestampToDate } from "~/utils/dates";
|
||||
|
|
@ -42,6 +53,7 @@ import {
|
|||
tournamentOrganizationPage,
|
||||
tournamentOrganizationStatsPage,
|
||||
tournamentPage,
|
||||
trophyTournamentsPage,
|
||||
userPage,
|
||||
} from "~/utils/urls";
|
||||
import { action } from "../actions/org.$slug.server";
|
||||
|
|
@ -71,7 +83,7 @@ export const meta: MetaFunction<typeof loader> = (args) => {
|
|||
};
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["badges", "org"],
|
||||
i18n: ["badges", "org", "trophies"],
|
||||
breadcrumb: ({ match }) => {
|
||||
const data = match.loaderData as SerializeFrom<typeof loader> | undefined;
|
||||
|
||||
|
|
@ -209,7 +221,7 @@ function LogoHeader() {
|
|||
}
|
||||
|
||||
function InfoTabs() {
|
||||
const { t } = useTranslation(["org"]);
|
||||
const { t } = useTranslation(["org", "trophies"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const isAdmin = useHasRole("ADMIN");
|
||||
const canBanPlayers = useHasPermission(data.organization, "BAN");
|
||||
|
|
@ -217,6 +229,8 @@ function InfoTabs() {
|
|||
const hasSocials =
|
||||
data.organization.socials && data.organization.socials.length > 0;
|
||||
const hasBadges = data.organization.badges.length > 0;
|
||||
const hasTrophies = data.trophies.length > 0;
|
||||
const hasRewards = hasBadges || hasTrophies;
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
|
@ -229,11 +243,11 @@ function InfoTabs() {
|
|||
{t("org:edit.form.members.title")}
|
||||
</SendouTab>
|
||||
<SendouTab
|
||||
id="badges"
|
||||
isDisabled={!hasBadges}
|
||||
icon={<Image path={navIconUrl("badges")} alt="" width={16} />}
|
||||
id="rewards"
|
||||
isDisabled={!hasRewards}
|
||||
icon={<Image path={navIconUrl("trophies")} alt="" width={16} />}
|
||||
>
|
||||
{t("org:edit.form.badges.title")}
|
||||
{t("org:edit.form.rewards.title")}
|
||||
</SendouTab>
|
||||
{canBanPlayers && data.bannedUsers ? (
|
||||
<SendouTab
|
||||
|
|
@ -256,8 +270,11 @@ function InfoTabs() {
|
|||
<SendouTabPanel id="members">
|
||||
<MembersList />
|
||||
</SendouTabPanel>
|
||||
<SendouTabPanel id="badges">
|
||||
<BadgeDisplay badges={data.organization.badges} />
|
||||
<SendouTabPanel id="rewards">
|
||||
<RewardsPanel
|
||||
badges={data.organization.badges}
|
||||
trophies={data.trophies}
|
||||
/>
|
||||
</SendouTabPanel>
|
||||
{data.bannedUsers ? (
|
||||
<SendouTabPanel id="banned-users">
|
||||
|
|
@ -274,6 +291,37 @@ function InfoTabs() {
|
|||
);
|
||||
}
|
||||
|
||||
function RewardsPanel({
|
||||
badges,
|
||||
trophies,
|
||||
}: {
|
||||
badges: SerializeFrom<typeof loader>["organization"]["badges"];
|
||||
trophies: SerializeFrom<typeof loader>["trophies"];
|
||||
}) {
|
||||
const { t } = useTranslation(["org", "trophies"]);
|
||||
|
||||
return (
|
||||
<div className="stack sm">
|
||||
{trophies.length > 0 ? (
|
||||
<>
|
||||
<Divider className="mt-2" smallText>
|
||||
{t("trophies:title")}
|
||||
</Divider>
|
||||
<RewardsTrophyGrid trophies={trophies} />
|
||||
</>
|
||||
) : null}
|
||||
{badges.length > 0 ? (
|
||||
<>
|
||||
<Divider className="mt-2" smallText>
|
||||
{t("org:edit.form.badges.title")}
|
||||
</Divider>
|
||||
<BadgeDisplay badges={badges} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminControls() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
|
|
@ -692,3 +740,74 @@ function EventLeaderboardRow({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RewardsTrophyGrid({
|
||||
trophies,
|
||||
}: {
|
||||
trophies: SerializeFrom<typeof loader>["trophies"];
|
||||
}) {
|
||||
const visibleCount = useProgressiveRender(trophies.length, "");
|
||||
const [openTrophy, setOpenTrophy] = React.useState<
|
||||
SerializeFrom<typeof loader>["trophies"][number] | null
|
||||
>(null);
|
||||
|
||||
return (
|
||||
<TrophyContextProvider>
|
||||
<TrophyGrid>
|
||||
{trophies.map((trophy, i) =>
|
||||
i < visibleCount ? (
|
||||
<button
|
||||
key={trophy.id}
|
||||
type="button"
|
||||
className={styles.trophyGridButton}
|
||||
onClick={() => setOpenTrophy(trophy)}
|
||||
aria-label={trophy.name}
|
||||
>
|
||||
<Trophy
|
||||
tile
|
||||
model={trophy.model}
|
||||
tier={trophy.tier}
|
||||
tentativeTier={trophy.tentativeTier}
|
||||
preview
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<TrophyPlaceholder key={trophy.id} />
|
||||
),
|
||||
)}
|
||||
</TrophyGrid>
|
||||
{openTrophy ? (
|
||||
<TrophyShowcaseModal
|
||||
trophy={openTrophy}
|
||||
onClose={() => setOpenTrophy(null)}
|
||||
>
|
||||
<TrophyModalTournaments
|
||||
key={openTrophy.id}
|
||||
trophyId={openTrophy.id}
|
||||
/>
|
||||
</TrophyShowcaseModal>
|
||||
) : null}
|
||||
</TrophyContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TrophyModalTournaments({ trophyId }: { trophyId: number }) {
|
||||
const { t } = useTranslation(["trophies"]);
|
||||
const fetcher = useFetcher<TrophyTournamentsLoaderData>();
|
||||
|
||||
const loadedRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
if (loadedRef.current) return;
|
||||
loadedRef.current = true;
|
||||
fetcher.load(trophyTournamentsPage(trophyId));
|
||||
}, [fetcher.load, trophyId]);
|
||||
|
||||
if (!fetcher.data || fetcher.data.tournaments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.trophyModalTournaments}>
|
||||
<Divider smallText>{t("trophies:details.tournamentHistory")}</Divider>
|
||||
<TrophyTournamentHistory tournaments={fetcher.data.tournaments} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
295
app/features/trophies/TrophyRepository.server.test.ts
Normal file
295
app/features/trophies/TrophyRepository.server.test.ts
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { db } from "~/db/sql";
|
||||
import type { TournamentTierNumber } from "~/features/tournament/core/tiering";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { dbInsertUsers, dbReset } from "~/utils/Test";
|
||||
import * as TrophyRepository from "./TrophyRepository.server";
|
||||
|
||||
describe("trophy approvals", () => {
|
||||
let pendingTrophyId: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
await dbInsertUsers(4);
|
||||
await db
|
||||
.insertInto("TournamentOrganization")
|
||||
.values({ name: "Test Org", slug: "test-org" })
|
||||
.execute();
|
||||
|
||||
const pending = await TrophyRepository.createPending({
|
||||
name: "Test Trophy",
|
||||
model: "model",
|
||||
description: "",
|
||||
organizationId: 1,
|
||||
submitterUserId: 1,
|
||||
});
|
||||
pendingTrophyId = pending.id;
|
||||
});
|
||||
|
||||
afterEach(() => dbReset());
|
||||
|
||||
test("creates the trophy exactly once when approvals exceed the required count", async () => {
|
||||
expect(
|
||||
await TrophyRepository.addApproval({ pendingTrophyId, userId: 2 }),
|
||||
).toBe(null);
|
||||
|
||||
const accepted = await TrophyRepository.addApproval({
|
||||
pendingTrophyId,
|
||||
userId: 3,
|
||||
});
|
||||
expect(accepted?.id).toBeTypeOf("number");
|
||||
|
||||
expect(
|
||||
await TrophyRepository.addApproval({ pendingTrophyId, userId: 4 }),
|
||||
).toBe(null);
|
||||
|
||||
expect(await trophyCount()).toBe(1);
|
||||
});
|
||||
|
||||
test("ignores repeated approvals from the same user", async () => {
|
||||
await TrophyRepository.addApproval({ pendingTrophyId, userId: 2 });
|
||||
|
||||
expect(
|
||||
await TrophyRepository.addApproval({ pendingTrophyId, userId: 2 }),
|
||||
).toBe(null);
|
||||
|
||||
const pending = await TrophyRepository.findPendingById(pendingTrophyId);
|
||||
expect(pending?.approvals.length).toBe(1);
|
||||
expect(await trophyCount()).toBe(0);
|
||||
});
|
||||
|
||||
test("re-approval after acceptance does not create another trophy", async () => {
|
||||
await TrophyRepository.addApproval({ pendingTrophyId, userId: 2 });
|
||||
await TrophyRepository.addApproval({ pendingTrophyId, userId: 3 });
|
||||
|
||||
expect(
|
||||
await TrophyRepository.addApproval({ pendingTrophyId, userId: 2 }),
|
||||
).toBe(null);
|
||||
|
||||
expect(await trophyCount()).toBe(1);
|
||||
});
|
||||
|
||||
test("declines a pending trophy that is not accepted", async () => {
|
||||
await TrophyRepository.addApproval({ pendingTrophyId, userId: 2 });
|
||||
|
||||
expect(
|
||||
await TrophyRepository.declinePending({
|
||||
id: pendingTrophyId,
|
||||
reason: "reason",
|
||||
declinedByUserId: 3,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
const pending = await TrophyRepository.findPendingById(pendingTrophyId);
|
||||
expect(pending?.declinedAt).not.toBe(null);
|
||||
expect(pending?.approvals.length).toBe(0);
|
||||
});
|
||||
|
||||
test("does not decline an already accepted pending trophy", async () => {
|
||||
await TrophyRepository.addApproval({ pendingTrophyId, userId: 2 });
|
||||
await TrophyRepository.addApproval({ pendingTrophyId, userId: 3 });
|
||||
|
||||
expect(
|
||||
await TrophyRepository.declinePending({
|
||||
id: pendingTrophyId,
|
||||
reason: "reason",
|
||||
declinedByUserId: 4,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
const pending = await TrophyRepository.findPendingById(pendingTrophyId);
|
||||
expect(pending?.declinedAt).toBe(null);
|
||||
expect(await trophyCount()).toBe(1);
|
||||
});
|
||||
|
||||
test("approvals after a decline do not create a trophy", async () => {
|
||||
await TrophyRepository.declinePending({
|
||||
id: pendingTrophyId,
|
||||
reason: "reason",
|
||||
declinedByUserId: 2,
|
||||
});
|
||||
|
||||
await TrophyRepository.addApproval({ pendingTrophyId, userId: 2 });
|
||||
expect(
|
||||
await TrophyRepository.addApproval({ pendingTrophyId, userId: 3 }),
|
||||
).toBe(null);
|
||||
|
||||
expect(await trophyCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("trophy list tiers", () => {
|
||||
let trophyId: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
await dbInsertUsers(2);
|
||||
|
||||
const trophy = await db
|
||||
.insertInto("Trophy")
|
||||
.values({
|
||||
name: "Tiered Trophy",
|
||||
model: "model",
|
||||
creatorId: 1,
|
||||
managerId: 1,
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
trophyId = trophy.id;
|
||||
});
|
||||
|
||||
afterEach(() => dbReset());
|
||||
|
||||
test("an upcoming tournament without tier info does not hide the earned tier", async () => {
|
||||
await insertTrophyTournament({ trophyId, tier: 3, startInDays: -21 });
|
||||
await insertTrophyTournament({ trophyId, tier: null, startInDays: 10 });
|
||||
|
||||
const trophy = (await TrophyRepository.all()).find(
|
||||
(row) => row.name === "Tiered Trophy",
|
||||
);
|
||||
|
||||
expect(trophy?.tier).toBe(3);
|
||||
});
|
||||
|
||||
test("uses the most recent tier when multiple tournaments have one", async () => {
|
||||
await insertTrophyTournament({ trophyId, tier: 5, startInDays: -30 });
|
||||
await insertTrophyTournament({ trophyId, tier: 3, startInDays: -7 });
|
||||
|
||||
const trophy = (await TrophyRepository.all()).find(
|
||||
(row) => row.name === "Tiered Trophy",
|
||||
);
|
||||
|
||||
expect(trophy?.tier).toBe(3);
|
||||
});
|
||||
|
||||
test("has no tier when no linked tournament has tier info", async () => {
|
||||
await insertTrophyTournament({ trophyId, tier: null, startInDays: 10 });
|
||||
|
||||
const trophy = (await TrophyRepository.all()).find(
|
||||
(row) => row.name === "Tiered Trophy",
|
||||
);
|
||||
|
||||
expect(trophy?.tier).toBe(null);
|
||||
expect(trophy?.tentativeTier).toBe(null);
|
||||
});
|
||||
|
||||
test("returns the start time of the next upcoming tournament", async () => {
|
||||
await insertTrophyTournament({ trophyId, tier: 3, startInDays: -21 });
|
||||
await insertTrophyTournament({ trophyId, tier: null, startInDays: 20 });
|
||||
await insertTrophyTournament({ trophyId, tier: null, startInDays: 10 });
|
||||
|
||||
const trophy = (await TrophyRepository.all()).find(
|
||||
(row) => row.name === "Tiered Trophy",
|
||||
);
|
||||
|
||||
const expected = dateToDatabaseTimestamp(
|
||||
new Date(Date.now() + 10 * 24 * 60 * 60 * 1000),
|
||||
);
|
||||
expect(
|
||||
Math.abs((trophy?.upcomingTournamentAt ?? 0) - expected),
|
||||
).toBeLessThan(10);
|
||||
});
|
||||
|
||||
test("sorts trophies with an upcoming tournament first within the same tier", async () => {
|
||||
await insertTrophyTournament({ trophyId, tier: 3, startInDays: -21 });
|
||||
|
||||
const upcoming = await db
|
||||
.insertInto("Trophy")
|
||||
.values({
|
||||
name: "Upcoming Trophy",
|
||||
model: "model",
|
||||
creatorId: 1,
|
||||
managerId: 1,
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await insertTrophyTournament({
|
||||
trophyId: upcoming.id,
|
||||
tier: 3,
|
||||
startInDays: -14,
|
||||
});
|
||||
await insertTrophyTournament({
|
||||
trophyId: upcoming.id,
|
||||
tier: null,
|
||||
startInDays: 10,
|
||||
});
|
||||
|
||||
const distant = await db
|
||||
.insertInto("Trophy")
|
||||
.values({
|
||||
name: "Distant Trophy",
|
||||
model: "model",
|
||||
creatorId: 1,
|
||||
managerId: 1,
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await insertTrophyTournament({
|
||||
trophyId: distant.id,
|
||||
tier: 3,
|
||||
startInDays: -7,
|
||||
});
|
||||
await insertTrophyTournament({
|
||||
trophyId: distant.id,
|
||||
tier: null,
|
||||
startInDays: 5 * 7,
|
||||
});
|
||||
|
||||
const names = (await TrophyRepository.all()).map((row) => row.name);
|
||||
|
||||
expect(names).toEqual([
|
||||
"Upcoming Trophy",
|
||||
"Tiered Trophy",
|
||||
"Distant Trophy",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
async function trophyCount() {
|
||||
const { count } = await db
|
||||
.selectFrom("Trophy")
|
||||
.select((eb) => eb.fn.countAll<number>().as("count"))
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
async function insertTrophyTournament({
|
||||
trophyId,
|
||||
tier,
|
||||
startInDays,
|
||||
}: {
|
||||
trophyId: number;
|
||||
tier: TournamentTierNumber | null;
|
||||
startInDays: number;
|
||||
}) {
|
||||
const tournament = await db
|
||||
.insertInto("Tournament")
|
||||
.values({
|
||||
mapPickingStyle: "AUTO_ALL",
|
||||
settings: JSON.stringify({ bracketProgression: [] }),
|
||||
tier,
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const event = await db
|
||||
.insertInto("CalendarEvent")
|
||||
.values({
|
||||
name: `Tournament ${tournament.id}`,
|
||||
bracketUrl: "https://example.com",
|
||||
authorId: 1,
|
||||
tournamentId: tournament.id,
|
||||
trophyId,
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
await db
|
||||
.insertInto("CalendarEventDate")
|
||||
.values({
|
||||
eventId: event.id,
|
||||
startsAt: dateToDatabaseTimestamp(
|
||||
new Date(Date.now() + startInDays * 24 * 60 * 60 * 1000),
|
||||
),
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
883
app/features/trophies/TrophyRepository.server.ts
Normal file
883
app/features/trophies/TrophyRepository.server.ts
Normal file
|
|
@ -0,0 +1,883 @@
|
|||
import { sub } from "date-fns";
|
||||
import type { ExpressionBuilder, NotNull, Transaction } from "kysely";
|
||||
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||
import * as R from "remeda";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB } from "~/db/tables";
|
||||
import { isSupporter } from "~/modules/permissions/utils";
|
||||
import {
|
||||
databaseTimestampToDate,
|
||||
dateToDatabaseTimestamp,
|
||||
} from "~/utils/dates";
|
||||
import {
|
||||
calendarEventStartTime,
|
||||
commonUserSelect,
|
||||
peakXpOverallSql,
|
||||
tournamentLogoWithDefault,
|
||||
tournamentTeamCount,
|
||||
} from "~/utils/kysely.server";
|
||||
import { getTentativeTier } from "../tournament-organization/core/tentativeTiers.server";
|
||||
import { sortTrophiesByFavorites } from "../user-page/core/trophy-sorting.server";
|
||||
import {
|
||||
SUPPORTER_TROPHY_CODE,
|
||||
TROPHY_APPROVALS_REQUIRED,
|
||||
XP_TROPHY_CODE_PREFIX,
|
||||
} from "./trophies-constants";
|
||||
import {
|
||||
hasUpcomingTournamentSoon,
|
||||
parseSpecialTrophyCode,
|
||||
} from "./trophies-utils";
|
||||
|
||||
type TrophyRecentTournament = {
|
||||
tier: number | null;
|
||||
name: string;
|
||||
organizationId: number | null;
|
||||
startTime: number | null;
|
||||
};
|
||||
|
||||
export async function all() {
|
||||
const rows = await db
|
||||
.selectFrom("Trophy")
|
||||
.select((eb) => ["id", "name", "model", withRecentTournaments(eb)])
|
||||
.execute();
|
||||
|
||||
return sortByEffectiveTier(rows.map(addEffectiveTier));
|
||||
}
|
||||
|
||||
const withRecentTournaments = (eb: ExpressionBuilder<DB, "Trophy">) =>
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("CalendarEvent")
|
||||
.innerJoin("Tournament", "Tournament.id", "CalendarEvent.tournamentId")
|
||||
.select((eb2) => [
|
||||
"Tournament.tier",
|
||||
"CalendarEvent.name",
|
||||
"CalendarEvent.organizationId",
|
||||
calendarEventStartTime(eb2).as("startTime"),
|
||||
])
|
||||
.whereRef("CalendarEvent.trophyId", "=", "Trophy.id")
|
||||
.where("CalendarEvent.hidden", "=", 0)
|
||||
.orderBy((eb2) => calendarEventStartTime(eb2), "desc"),
|
||||
).as("recentTournaments");
|
||||
|
||||
function addEffectiveTier<
|
||||
T extends { recentTournaments: Array<TrophyRecentTournament> },
|
||||
>({ recentTournaments, ...rest }: T) {
|
||||
const upcomingTournamentAt = nextUpcomingStartTime(recentTournaments);
|
||||
|
||||
for (const tournament of recentTournaments) {
|
||||
const tierInfo = tournamentTierInfo(tournament);
|
||||
if (tierInfo.tier !== null || tierInfo.tentativeTier !== null) {
|
||||
return { ...rest, ...tierInfo, upcomingTournamentAt };
|
||||
}
|
||||
}
|
||||
|
||||
return { ...rest, tier: null, tentativeTier: null, upcomingTournamentAt };
|
||||
}
|
||||
|
||||
function nextUpcomingStartTime(tournaments: Array<TrophyRecentTournament>) {
|
||||
const now = dateToDatabaseTimestamp(new Date());
|
||||
|
||||
// ordered newest first, so the last future start time is the next one up
|
||||
const futureStartTimes = tournaments
|
||||
.map((tournament) => tournament.startTime)
|
||||
.filter(
|
||||
(startTime): startTime is number => startTime !== null && startTime > now,
|
||||
);
|
||||
|
||||
return futureStartTimes.at(-1) ?? null;
|
||||
}
|
||||
|
||||
function tournamentTierInfo(tournament: TrophyRecentTournament) {
|
||||
const isPastEvent =
|
||||
tournament.startTime !== null &&
|
||||
databaseTimestampToDate(tournament.startTime) <
|
||||
sub(new Date(), { days: 1 });
|
||||
|
||||
const tentativeTier =
|
||||
tournament.tier === null &&
|
||||
tournament.organizationId !== null &&
|
||||
!isPastEvent
|
||||
? getTentativeTier(tournament.organizationId, tournament.name)
|
||||
: null;
|
||||
|
||||
return { tier: tournament.tier, tentativeTier };
|
||||
}
|
||||
|
||||
function sortByEffectiveTier<
|
||||
T extends {
|
||||
id: number;
|
||||
tier: number | null;
|
||||
tentativeTier: number | null;
|
||||
upcomingTournamentAt: number | null;
|
||||
},
|
||||
>(rows: T[]) {
|
||||
return R.sortBy(
|
||||
rows,
|
||||
(row) => row.tier ?? row.tentativeTier ?? Number.MAX_SAFE_INTEGER,
|
||||
(row) => (hasUpcomingTournamentSoon(row.upcomingTournamentAt) ? 0 : 1),
|
||||
(row) => row.id,
|
||||
);
|
||||
}
|
||||
|
||||
const withCreator = (eb: ExpressionBuilder<DB, "Trophy">) => {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom("User")
|
||||
.select((eb) => commonUserSelect(eb))
|
||||
.whereRef("User.id", "=", "Trophy.creatorId"),
|
||||
).as("creator");
|
||||
};
|
||||
|
||||
const withManager = (eb: ExpressionBuilder<DB, "Trophy">) => {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom("User")
|
||||
.select((eb) => commonUserSelect(eb))
|
||||
.whereRef("User.id", "=", "Trophy.managerId"),
|
||||
).as("manager");
|
||||
};
|
||||
|
||||
const withOrganization = (eb: ExpressionBuilder<DB, "Trophy">) => {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom("TournamentOrganization")
|
||||
.select(["TournamentOrganization.name", "TournamentOrganization.slug"])
|
||||
.whereRef("TournamentOrganization.id", "=", "Trophy.organizationId"),
|
||||
).as("organization");
|
||||
};
|
||||
|
||||
const withOwners = (eb: ExpressionBuilder<DB, "Trophy">) => {
|
||||
return jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("TrophyOwner")
|
||||
.innerJoin("User", "TrophyOwner.userId", "User.id")
|
||||
.select((eb) => [
|
||||
eb.fn.count<number>("TrophyOwner.trophyId").as("count"),
|
||||
...commonUserSelect(eb),
|
||||
])
|
||||
.whereRef("TrophyOwner.trophyId", "=", "Trophy.id")
|
||||
.groupBy("User.id")
|
||||
.orderBy("count", "desc"),
|
||||
).as("owners");
|
||||
};
|
||||
|
||||
const withSpecialOwners = (eb: ExpressionBuilder<DB, "Trophy">) => {
|
||||
return jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("SpecialTrophyOwner")
|
||||
.innerJoin("User", "SpecialTrophyOwner.userId", "User.id")
|
||||
.select((eb) => [eb.val(1).as("count"), ...commonUserSelect(eb)])
|
||||
.whereRef("SpecialTrophyOwner.trophyId", "=", "Trophy.id")
|
||||
.orderBy("User.id", "asc"),
|
||||
).as("specialOwners");
|
||||
};
|
||||
|
||||
export async function findByOrganizationId(organizationId: number) {
|
||||
const rows = await db
|
||||
.selectFrom("Trophy")
|
||||
.select((eb) => ["id", "name", "model", withRecentTournaments(eb)])
|
||||
.where("organizationId", "=", organizationId)
|
||||
.execute();
|
||||
|
||||
return sortByEffectiveTier(rows.map(addEffectiveTier));
|
||||
}
|
||||
|
||||
export async function findByOrganizationIds(organizationIds: number[]) {
|
||||
if (organizationIds.length === 0) return [];
|
||||
|
||||
return db
|
||||
.selectFrom("Trophy")
|
||||
.select(["id", "name", "model", "organizationId"])
|
||||
.where("organizationId", "in", organizationIds)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function findOwnedTrophies(userId: number) {
|
||||
const tournamentRows = await db
|
||||
.selectFrom("TrophyOwner")
|
||||
.innerJoin("Trophy", "Trophy.id", "TrophyOwner.trophyId")
|
||||
.innerJoin("User", "User.id", "TrophyOwner.userId")
|
||||
.select(({ fn }) => [
|
||||
fn.count<number>("TrophyOwner.trophyId").as("count"),
|
||||
fn.min<number | null>("TrophyOwner.tier").as("tier"),
|
||||
"Trophy.id",
|
||||
"Trophy.name",
|
||||
"Trophy.model",
|
||||
"Trophy.code",
|
||||
"User.favoriteTrophyIds",
|
||||
"User.hiddenTrophyIds",
|
||||
"User.patronTier",
|
||||
])
|
||||
.where("TrophyOwner.userId", "=", userId)
|
||||
.groupBy(["TrophyOwner.trophyId", "TrophyOwner.userId"])
|
||||
.execute();
|
||||
|
||||
const specialRows = await db
|
||||
.selectFrom("SpecialTrophyOwner")
|
||||
.innerJoin("Trophy", "Trophy.id", "SpecialTrophyOwner.trophyId")
|
||||
.innerJoin("User", "User.id", "SpecialTrophyOwner.userId")
|
||||
.select([
|
||||
"Trophy.id",
|
||||
"Trophy.name",
|
||||
"Trophy.model",
|
||||
"Trophy.code",
|
||||
"User.favoriteTrophyIds",
|
||||
"User.hiddenTrophyIds",
|
||||
"User.patronTier",
|
||||
])
|
||||
.where("SpecialTrophyOwner.userId", "=", userId)
|
||||
.execute();
|
||||
|
||||
return [
|
||||
...tournamentRows,
|
||||
...specialRows.map((row) => ({ ...row, count: 1, tier: null })),
|
||||
];
|
||||
}
|
||||
|
||||
export async function findByOwnerUserId(userId: number) {
|
||||
const rows = await findOwnedTrophies(userId);
|
||||
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const { favoriteTrophyIds, hiddenTrophyIds, patronTier } = rows[0];
|
||||
const hiddenSet = new Set(hiddenTrophyIds ?? []);
|
||||
|
||||
return sortTrophiesByFavorites({
|
||||
favoriteTrophyIds,
|
||||
hiddenTrophyIds,
|
||||
patronTier,
|
||||
trophies: rows
|
||||
.filter((row) => !hiddenSet.has(row.id))
|
||||
.map((row) =>
|
||||
R.omit(row, ["favoriteTrophyIds", "hiddenTrophyIds", "patronTier"]),
|
||||
),
|
||||
}).trophies;
|
||||
}
|
||||
|
||||
export async function findByOwnerUserIdIncludingHidden(userId: number) {
|
||||
const rows = await findOwnedTrophies(userId);
|
||||
|
||||
return rows.map((row) =>
|
||||
R.omit(row, ["favoriteTrophyIds", "hiddenTrophyIds", "patronTier"]),
|
||||
);
|
||||
}
|
||||
|
||||
export async function findById(trophyId: number) {
|
||||
const row = await db
|
||||
.selectFrom("Trophy")
|
||||
.select((eb) => [
|
||||
"Trophy.id",
|
||||
"Trophy.name",
|
||||
"Trophy.model",
|
||||
"Trophy.code",
|
||||
withCreator(eb),
|
||||
withManager(eb),
|
||||
withOrganization(eb),
|
||||
withOwners(eb),
|
||||
withSpecialOwners(eb),
|
||||
])
|
||||
.where("Trophy.id", "=", trophyId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
const { specialOwners, ...trophy } = row;
|
||||
|
||||
return { ...trophy, owners: [...trophy.owners, ...specialOwners] };
|
||||
}
|
||||
|
||||
export async function findTournamentsByTrophyId(trophyId: number) {
|
||||
const rows = await db
|
||||
.selectFrom("CalendarEvent")
|
||||
.innerJoin("Tournament", "Tournament.id", "CalendarEvent.tournamentId")
|
||||
.select((eb) => [
|
||||
"Tournament.id as tournamentId",
|
||||
"CalendarEvent.name",
|
||||
"CalendarEvent.organizationId",
|
||||
"Tournament.tier",
|
||||
tournamentLogoWithDefault(eb).as("logoUrl"),
|
||||
calendarEventStartTime(eb).as("startTime"),
|
||||
tournamentTeamCount(eb).as("teamsCount"),
|
||||
])
|
||||
.where("CalendarEvent.trophyId", "=", trophyId)
|
||||
.where("CalendarEvent.hidden", "=", 0)
|
||||
.orderBy("startTime", "desc")
|
||||
.execute();
|
||||
|
||||
return rows.map((row) => ({
|
||||
tournamentId: row.tournamentId,
|
||||
name: row.name,
|
||||
logoUrl: row.logoUrl,
|
||||
startTime: row.startTime,
|
||||
teamsCount: row.teamsCount,
|
||||
...tournamentTierInfo(row),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function findWinsByOwner({
|
||||
trophyId,
|
||||
userId,
|
||||
}: {
|
||||
trophyId: number;
|
||||
userId: number;
|
||||
}) {
|
||||
const tournaments = await db
|
||||
.selectFrom("TrophyOwner")
|
||||
.innerJoin("Tournament", "Tournament.id", "TrophyOwner.tournamentId")
|
||||
.innerJoin("CalendarEvent", "CalendarEvent.tournamentId", "Tournament.id")
|
||||
.select((eb) => [
|
||||
"TrophyOwner.tournamentId",
|
||||
"TrophyOwner.tier",
|
||||
"CalendarEvent.name",
|
||||
tournamentLogoWithDefault(eb).as("logoUrl"),
|
||||
calendarEventStartTime(eb).as("startTime"),
|
||||
tournamentTeamCount(eb).as("teamsCount"),
|
||||
])
|
||||
.where("TrophyOwner.trophyId", "=", trophyId)
|
||||
.where("TrophyOwner.userId", "=", userId)
|
||||
.where("CalendarEvent.hidden", "=", 0)
|
||||
.orderBy("startTime", "desc")
|
||||
.execute();
|
||||
|
||||
if (tournaments.length === 0) return [];
|
||||
|
||||
const tournamentIds = tournaments.map(
|
||||
(tournament) => tournament.tournamentId,
|
||||
);
|
||||
|
||||
const ownResults = await db
|
||||
.selectFrom("TournamentResult")
|
||||
.select(["tournamentId", "tournamentTeamId"])
|
||||
.where("userId", "=", userId)
|
||||
.where("tournamentId", "in", tournamentIds)
|
||||
.execute();
|
||||
|
||||
const teamIds = ownResults.map((result) => result.tournamentTeamId);
|
||||
|
||||
const memberRows =
|
||||
teamIds.length > 0
|
||||
? await db
|
||||
.selectFrom("TournamentResult")
|
||||
.innerJoin("User", "User.id", "TournamentResult.userId")
|
||||
.select((eb) => [
|
||||
"TournamentResult.tournamentTeamId",
|
||||
"TournamentResult.setResults",
|
||||
...commonUserSelect(eb),
|
||||
])
|
||||
.where("TournamentResult.tournamentTeamId", "in", teamIds)
|
||||
.execute()
|
||||
: [];
|
||||
|
||||
const memberIds = R.unique(memberRows.map((member) => member.id));
|
||||
|
||||
const weaponRows =
|
||||
memberIds.length > 0
|
||||
? await db
|
||||
.selectFrom("ReportedWeapon")
|
||||
.innerJoin(
|
||||
"TournamentMatch",
|
||||
"TournamentMatch.id",
|
||||
"ReportedWeapon.tournamentMatchId",
|
||||
)
|
||||
.innerJoin(
|
||||
"TournamentStage",
|
||||
"TournamentStage.id",
|
||||
"TournamentMatch.stageId",
|
||||
)
|
||||
.select(({ fn }) => [
|
||||
"TournamentStage.tournamentId",
|
||||
"ReportedWeapon.userId",
|
||||
"ReportedWeapon.weaponSplId",
|
||||
fn.countAll<number>().as("count"),
|
||||
])
|
||||
.where("TournamentStage.tournamentId", "in", tournamentIds)
|
||||
.where("ReportedWeapon.userId", "in", memberIds)
|
||||
.groupBy([
|
||||
"TournamentStage.tournamentId",
|
||||
"ReportedWeapon.userId",
|
||||
"ReportedWeapon.weaponSplId",
|
||||
])
|
||||
.orderBy("count", "desc")
|
||||
.execute()
|
||||
: [];
|
||||
|
||||
return tournaments.map((tournament) => {
|
||||
const ownResult = ownResults.find(
|
||||
(result) => result.tournamentId === tournament.tournamentId,
|
||||
);
|
||||
|
||||
const members = memberRows
|
||||
.filter(
|
||||
(member) => member.tournamentTeamId === ownResult?.tournamentTeamId,
|
||||
)
|
||||
.map((member) => ({
|
||||
...R.omit(member, ["tournamentTeamId"]),
|
||||
weapons: weaponRows
|
||||
.filter(
|
||||
(weapon) =>
|
||||
weapon.tournamentId === tournament.tournamentId &&
|
||||
weapon.userId === member.id,
|
||||
)
|
||||
.map((weapon) => weapon.weaponSplId),
|
||||
}));
|
||||
|
||||
return {
|
||||
...tournament,
|
||||
members: R.sortBy(
|
||||
members,
|
||||
(member) => (member.id === userId ? 0 : 1),
|
||||
(member) => member.username.toLowerCase(),
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function findOrganizationIdById(trophyId: number) {
|
||||
const row = await db
|
||||
.selectFrom("Trophy")
|
||||
.select("organizationId")
|
||||
.where("id", "=", trophyId)
|
||||
.executeTakeFirst();
|
||||
|
||||
return row?.organizationId ?? null;
|
||||
}
|
||||
|
||||
export async function existsByName(args: {
|
||||
name: string;
|
||||
excludeTrophyId?: number;
|
||||
}) {
|
||||
let trophyQuery = db
|
||||
.selectFrom("Trophy")
|
||||
.select("id")
|
||||
.where("name", "=", args.name);
|
||||
|
||||
if (args.excludeTrophyId !== undefined) {
|
||||
trophyQuery = trophyQuery.where("id", "!=", args.excludeTrophyId);
|
||||
}
|
||||
|
||||
const trophy = await trophyQuery.executeTakeFirst();
|
||||
|
||||
if (trophy) return true;
|
||||
|
||||
let pendingQuery = db
|
||||
.selectFrom("PendingTrophy")
|
||||
.select("id")
|
||||
.where("name", "=", args.name)
|
||||
.where("declinedAt", "is", null);
|
||||
|
||||
if (args.excludeTrophyId !== undefined) {
|
||||
pendingQuery = pendingQuery.where(
|
||||
"targetTrophyId",
|
||||
"is not",
|
||||
args.excludeTrophyId,
|
||||
);
|
||||
}
|
||||
|
||||
const pending = await pendingQuery.executeTakeFirst();
|
||||
|
||||
return Boolean(pending);
|
||||
}
|
||||
|
||||
export async function findManagedBy(userId: number) {
|
||||
return db
|
||||
.selectFrom("Trophy")
|
||||
.select(["id", "name", "model", "organizationId", "managerId"])
|
||||
.where("managerId", "=", userId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function findAllForEditing() {
|
||||
return db
|
||||
.selectFrom("Trophy")
|
||||
.select(["id", "name", "model", "organizationId", "managerId"])
|
||||
.where("code", "is", null)
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes ownership of every special trophy (supporter, XP).
|
||||
* Existing owner rows that are still eligible keep their original `createdAt`.
|
||||
*/
|
||||
export function syncSpecialTrophies() {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
await syncSupporterTrophyOwners(trx);
|
||||
await syncXpTrophyOwners(trx);
|
||||
});
|
||||
}
|
||||
|
||||
async function syncSupporterTrophyOwners(trx: Transaction<DB>) {
|
||||
const trophy = await trx
|
||||
.selectFrom("Trophy")
|
||||
.select("id")
|
||||
.where("code", "=", SUPPORTER_TROPHY_CODE)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!trophy) return;
|
||||
|
||||
const patrons = await trx
|
||||
.selectFrom("User")
|
||||
.select(["id", "patronTier"])
|
||||
.where("patronTier", "is not", null)
|
||||
.execute();
|
||||
|
||||
await replaceSpecialTrophyOwners({
|
||||
trx,
|
||||
trophyId: trophy.id,
|
||||
userIds: patrons.filter(isSupporter).map((patron) => patron.id),
|
||||
});
|
||||
}
|
||||
|
||||
async function syncXpTrophyOwners(trx: Transaction<DB>) {
|
||||
const xpTrophies = (
|
||||
await trx
|
||||
.selectFrom("Trophy")
|
||||
.select(["id", "code"])
|
||||
.where("code", "like", `${XP_TROPHY_CODE_PREFIX}%`)
|
||||
.execute()
|
||||
).flatMap((trophy) => {
|
||||
const parsed = parseSpecialTrophyCode(trophy.code);
|
||||
return parsed?.type === "xp"
|
||||
? [{ id: trophy.id, value: parsed.value }]
|
||||
: [];
|
||||
});
|
||||
|
||||
if (xpTrophies.length === 0) return;
|
||||
|
||||
const byValueDesc = R.sortBy(xpTrophies, [(trophy) => trophy.value, "desc"]);
|
||||
|
||||
const userPeakXps = await trx
|
||||
.selectFrom("SplatoonPlayer")
|
||||
.select(["userId", peakXpOverallSql().as("peakXp")])
|
||||
.where("userId", "is not", null)
|
||||
.where("peakXp", "is not", null)
|
||||
.$narrowType<{ userId: NotNull; peakXp: NotNull }>()
|
||||
.execute();
|
||||
|
||||
const ownersByTrophyId = new Map<number, number[]>(
|
||||
xpTrophies.map((trophy) => [trophy.id, []]),
|
||||
);
|
||||
for (const { userId, peakXp } of userPeakXps) {
|
||||
const highestReached = byValueDesc.find((trophy) => peakXp >= trophy.value);
|
||||
if (!highestReached) continue;
|
||||
|
||||
ownersByTrophyId.get(highestReached.id)?.push(userId);
|
||||
}
|
||||
|
||||
for (const [trophyId, userIds] of ownersByTrophyId) {
|
||||
await replaceSpecialTrophyOwners({ trx, trophyId, userIds });
|
||||
}
|
||||
}
|
||||
|
||||
async function replaceSpecialTrophyOwners({
|
||||
trx,
|
||||
trophyId,
|
||||
userIds,
|
||||
}: {
|
||||
trx: Transaction<DB>;
|
||||
trophyId: number;
|
||||
userIds: number[];
|
||||
}) {
|
||||
let deleteStale = trx
|
||||
.deleteFrom("SpecialTrophyOwner")
|
||||
.where("trophyId", "=", trophyId);
|
||||
if (userIds.length > 0) {
|
||||
deleteStale = deleteStale.where("userId", "not in", userIds);
|
||||
}
|
||||
await deleteStale.execute();
|
||||
|
||||
if (userIds.length === 0) return;
|
||||
|
||||
await trx
|
||||
.insertInto("SpecialTrophyOwner")
|
||||
.values(
|
||||
userIds.map((userId) => ({
|
||||
trophyId,
|
||||
userId,
|
||||
createdAt: dateToDatabaseTimestamp(new Date()),
|
||||
})),
|
||||
)
|
||||
.onConflict((oc) => oc.doNothing())
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function createPending(args: {
|
||||
name: string;
|
||||
model: string;
|
||||
description: string;
|
||||
organizationId: number;
|
||||
submitterUserId: number;
|
||||
targetTrophyId?: number;
|
||||
managerId?: number;
|
||||
}) {
|
||||
return db
|
||||
.insertInto("PendingTrophy")
|
||||
.values({
|
||||
name: args.name,
|
||||
model: args.model,
|
||||
description: args.description,
|
||||
organizationId: args.organizationId,
|
||||
submitterUserId: args.submitterUserId,
|
||||
createdAt: dateToDatabaseTimestamp(new Date()),
|
||||
declineReason: null,
|
||||
declinedAt: null,
|
||||
declinedByUserId: null,
|
||||
targetTrophyId: args.targetTrophyId ?? null,
|
||||
managerId: args.managerId ?? null,
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
const withApprovals = (eb: ExpressionBuilder<DB, "PendingTrophy">) => {
|
||||
return jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("PendingTrophyApproval")
|
||||
.innerJoin("User", "PendingTrophyApproval.userId", "User.id")
|
||||
.select([
|
||||
"PendingTrophyApproval.userId",
|
||||
"PendingTrophyApproval.createdAt",
|
||||
"User.username",
|
||||
])
|
||||
.whereRef(
|
||||
"PendingTrophyApproval.pendingTrophyId",
|
||||
"=",
|
||||
"PendingTrophy.id",
|
||||
)
|
||||
.orderBy("PendingTrophyApproval.createdAt", "asc"),
|
||||
).as("approvals");
|
||||
};
|
||||
|
||||
const withTarget = (eb: ExpressionBuilder<DB, "PendingTrophy">) => {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom("Trophy")
|
||||
.leftJoin("User", "User.id", "Trophy.managerId")
|
||||
.leftJoin(
|
||||
"TournamentOrganization",
|
||||
"TournamentOrganization.id",
|
||||
"Trophy.organizationId",
|
||||
)
|
||||
.select([
|
||||
"Trophy.id",
|
||||
"Trophy.name",
|
||||
"Trophy.model",
|
||||
"Trophy.organizationId",
|
||||
"Trophy.managerId",
|
||||
"User.username as managerUsername",
|
||||
"TournamentOrganization.name as organizationName",
|
||||
"TournamentOrganization.slug as organizationSlug",
|
||||
])
|
||||
.whereRef("Trophy.id", "=", "PendingTrophy.targetTrophyId"),
|
||||
).as("target");
|
||||
};
|
||||
|
||||
const withTargetManager = (eb: ExpressionBuilder<DB, "PendingTrophy">) => {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom("User")
|
||||
.select(["User.id", "User.username", "User.discordId"])
|
||||
.whereRef("User.id", "=", "PendingTrophy.managerId"),
|
||||
).as("manager");
|
||||
};
|
||||
|
||||
function pendingBaseQuery() {
|
||||
return db
|
||||
.selectFrom("PendingTrophy")
|
||||
.leftJoin(
|
||||
"User as Submitter",
|
||||
"Submitter.id",
|
||||
"PendingTrophy.submitterUserId",
|
||||
)
|
||||
.leftJoin(
|
||||
"User as Decliner",
|
||||
"Decliner.id",
|
||||
"PendingTrophy.declinedByUserId",
|
||||
)
|
||||
.leftJoin(
|
||||
"TournamentOrganization",
|
||||
"TournamentOrganization.id",
|
||||
"PendingTrophy.organizationId",
|
||||
)
|
||||
.select((eb) => [
|
||||
"PendingTrophy.id",
|
||||
"PendingTrophy.name",
|
||||
"PendingTrophy.model",
|
||||
"PendingTrophy.description",
|
||||
"PendingTrophy.organizationId",
|
||||
"PendingTrophy.submitterUserId",
|
||||
"PendingTrophy.createdAt",
|
||||
"PendingTrophy.declineReason",
|
||||
"PendingTrophy.declinedAt",
|
||||
"PendingTrophy.declinedByUserId",
|
||||
"PendingTrophy.targetTrophyId",
|
||||
"PendingTrophy.managerId",
|
||||
"Submitter.username as submitterUsername",
|
||||
"Submitter.discordId as submitterDiscordId",
|
||||
"Decliner.username as declinedByUsername",
|
||||
"TournamentOrganization.name as organizationName",
|
||||
"TournamentOrganization.slug as organizationSlug",
|
||||
withApprovals(eb),
|
||||
withTarget(eb),
|
||||
withTargetManager(eb),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function findPendingById(id: number) {
|
||||
const row = await pendingBaseQuery()
|
||||
.where("PendingTrophy.id", "=", id)
|
||||
.executeTakeFirst();
|
||||
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function allPending() {
|
||||
return pendingBaseQuery()
|
||||
.orderBy("PendingTrophy.createdAt", "desc")
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function pendingBySubmitter(submitterUserId: number) {
|
||||
return pendingBaseQuery()
|
||||
.where("PendingTrophy.submitterUserId", "=", submitterUserId)
|
||||
.orderBy("PendingTrophy.createdAt", "desc")
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function unreviewedCountBySubmitter(submitterUserId: number) {
|
||||
const row = await db
|
||||
.selectFrom("PendingTrophy")
|
||||
.select((eb) => eb.fn.countAll<number>().as("count"))
|
||||
.where("submitterUserId", "=", submitterUserId)
|
||||
.where("declinedAt", "is", null)
|
||||
.where((eb) =>
|
||||
eb(
|
||||
eb
|
||||
.selectFrom("PendingTrophyApproval")
|
||||
.select((eb2) => eb2.fn.countAll<number>().as("approvalCount"))
|
||||
.whereRef(
|
||||
"PendingTrophyApproval.pendingTrophyId",
|
||||
"=",
|
||||
"PendingTrophy.id",
|
||||
),
|
||||
"<",
|
||||
TROPHY_APPROVALS_REQUIRED,
|
||||
),
|
||||
)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return row.count;
|
||||
}
|
||||
|
||||
export async function deletePending(id: number) {
|
||||
await db.deleteFrom("PendingTrophy").where("id", "=", id).execute();
|
||||
}
|
||||
|
||||
export async function declinePending(args: {
|
||||
id: number;
|
||||
reason: string;
|
||||
declinedByUserId: number;
|
||||
}) {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
const { count } = await trx
|
||||
.selectFrom("PendingTrophyApproval")
|
||||
.select((eb) => eb.fn.countAll<number>().as("count"))
|
||||
.where("pendingTrophyId", "=", args.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
if (count >= TROPHY_APPROVALS_REQUIRED) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await trx
|
||||
.deleteFrom("PendingTrophyApproval")
|
||||
.where("pendingTrophyId", "=", args.id)
|
||||
.execute();
|
||||
|
||||
await trx
|
||||
.updateTable("PendingTrophy")
|
||||
.set({
|
||||
declineReason: args.reason,
|
||||
declinedAt: dateToDatabaseTimestamp(new Date()),
|
||||
declinedByUserId: args.declinedByUserId,
|
||||
})
|
||||
.where("id", "=", args.id)
|
||||
.execute();
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function addApproval(args: {
|
||||
pendingTrophyId: number;
|
||||
userId: number;
|
||||
}) {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
const insertResult = await trx
|
||||
.insertInto("PendingTrophyApproval")
|
||||
.values({
|
||||
pendingTrophyId: args.pendingTrophyId,
|
||||
userId: args.userId,
|
||||
createdAt: dateToDatabaseTimestamp(new Date()),
|
||||
})
|
||||
.onConflict((oc) => oc.doNothing())
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!insertResult.numInsertedOrUpdatedRows) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { count } = await trx
|
||||
.selectFrom("PendingTrophyApproval")
|
||||
.select((eb) => eb.fn.countAll<number>().as("count"))
|
||||
.where("pendingTrophyId", "=", args.pendingTrophyId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
if (count !== TROPHY_APPROVALS_REQUIRED) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pending = await trx
|
||||
.selectFrom("PendingTrophy")
|
||||
.select([
|
||||
"id",
|
||||
"name",
|
||||
"model",
|
||||
"organizationId",
|
||||
"submitterUserId",
|
||||
"targetTrophyId",
|
||||
"managerId",
|
||||
])
|
||||
.where("id", "=", args.pendingTrophyId)
|
||||
.where("declinedAt", "is", null)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!pending) return null;
|
||||
|
||||
if (pending.targetTrophyId !== null) {
|
||||
await trx
|
||||
.updateTable("Trophy")
|
||||
.set({
|
||||
name: pending.name,
|
||||
model: pending.model,
|
||||
organizationId: pending.organizationId,
|
||||
managerId: pending.managerId ?? pending.submitterUserId,
|
||||
})
|
||||
.where("id", "=", pending.targetTrophyId)
|
||||
.execute();
|
||||
return { id: pending.targetTrophyId };
|
||||
}
|
||||
|
||||
return trx
|
||||
.insertInto("Trophy")
|
||||
.values({
|
||||
name: pending.name,
|
||||
model: pending.model,
|
||||
organizationId: pending.organizationId,
|
||||
creatorId: pending.submitterUserId,
|
||||
managerId: pending.managerId ?? pending.submitterUserId,
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
});
|
||||
}
|
||||
231
app/features/trophies/actions/trophies.new.server.ts
Normal file
231
app/features/trophies/actions/trophies.new.server.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import type { ActionFunction } from "react-router";
|
||||
import { ADMIN_ID, QA_IDS } from "~/features/admin/admin-constants";
|
||||
import {
|
||||
type AuthenticatedUser,
|
||||
requireUser,
|
||||
} from "~/features/auth/core/user.server";
|
||||
import { notify } from "~/features/notifications/core/notify.server";
|
||||
import { clearTrophiesCache } from "~/features/trophies/loaders/trophies.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { parseFormData } from "~/form/parse.server";
|
||||
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import * as TrophyRepository from "../TrophyRepository.server";
|
||||
import {
|
||||
TROPHY_APPROVALS_REQUIRED,
|
||||
TROPHY_PENDING_PER_USER_LIMIT,
|
||||
} from "../trophies-constants";
|
||||
import {
|
||||
pendingTrophyActionSchema,
|
||||
trophyFormSchema,
|
||||
} from "../trophies-schemas";
|
||||
import {
|
||||
canAccessTrophies,
|
||||
canEditTrophy,
|
||||
canReviewTrophies,
|
||||
compressTrophyModel,
|
||||
} from "../trophies-utils";
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = requireUser();
|
||||
if (!canAccessTrophies(user)) {
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
const isJson = request.headers.get("Content-Type") === "application/json";
|
||||
|
||||
if (isJson) {
|
||||
const result = await parseFormData({
|
||||
request,
|
||||
schema: trophyFormSchema,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { fieldErrors: result.fieldErrors };
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
|
||||
const pendingCount = await TrophyRepository.unreviewedCountBySubmitter(
|
||||
user.id,
|
||||
);
|
||||
errorToastIfFalsy(
|
||||
pendingCount < TROPHY_PENDING_PER_USER_LIMIT,
|
||||
"Pending trophy limit reached",
|
||||
);
|
||||
|
||||
if (data._action === "UPDATE") {
|
||||
const trophy = await TrophyRepository.findById(data.targetTrophyId);
|
||||
errorToastIfFalsy(trophy, "Trophy not found");
|
||||
errorToastIfFalsy(
|
||||
canEditTrophy(user, { managerId: trophy.manager?.id ?? null }),
|
||||
"Not allowed",
|
||||
);
|
||||
|
||||
const nameExists = await TrophyRepository.existsByName({
|
||||
name: data.name,
|
||||
excludeTrophyId: data.targetTrophyId,
|
||||
});
|
||||
if (nameExists) {
|
||||
return { fieldErrors: { name: "forms:errors.trophyNameTaken" } };
|
||||
}
|
||||
|
||||
await TrophyRepository.createPending({
|
||||
name: data.name,
|
||||
model: compressTrophyModel(data.model),
|
||||
description: data.description ?? "",
|
||||
organizationId: data.organizationId,
|
||||
submitterUserId: user.id,
|
||||
targetTrophyId: data.targetTrophyId,
|
||||
managerId: data.managerId,
|
||||
});
|
||||
|
||||
await notifyReviewersOfSubmission({
|
||||
trophyName: data.name,
|
||||
submitter: user,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const nameExists = await TrophyRepository.existsByName({
|
||||
name: data.name,
|
||||
});
|
||||
if (nameExists) {
|
||||
return { fieldErrors: { name: "forms:errors.trophyNameTaken" } };
|
||||
}
|
||||
|
||||
await TrophyRepository.createPending({
|
||||
name: data.name,
|
||||
model: compressTrophyModel(data.model),
|
||||
description: data.description ?? "",
|
||||
organizationId: data.organizationId,
|
||||
submitterUserId: user.id,
|
||||
});
|
||||
|
||||
await notifyReviewersOfSubmission({
|
||||
trophyName: data.name,
|
||||
submitter: user,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: pendingTrophyActionSchema,
|
||||
});
|
||||
|
||||
switch (data._action) {
|
||||
case "DELETE": {
|
||||
const pending = await TrophyRepository.findPendingById(
|
||||
data.pendingTrophyId,
|
||||
);
|
||||
errorToastIfFalsy(pending, "Pending trophy not found");
|
||||
|
||||
const isOwner = pending.submitterUserId === user.id;
|
||||
const canReview = canReviewTrophies(user);
|
||||
errorToastIfFalsy(isOwner || canReview, "Not allowed");
|
||||
|
||||
await TrophyRepository.deletePending(data.pendingTrophyId);
|
||||
return null;
|
||||
}
|
||||
case "DECLINE": {
|
||||
errorToastIfFalsy(canReviewTrophies(user), "Not allowed");
|
||||
|
||||
const pending = await TrophyRepository.findPendingById(
|
||||
data.pendingTrophyId,
|
||||
);
|
||||
errorToastIfFalsy(pending, "Pending trophy not found");
|
||||
errorToastIfFalsy(!pending.declinedAt, "Trophy is already declined");
|
||||
errorToastIfFalsy(
|
||||
pending.approvals.length < TROPHY_APPROVALS_REQUIRED,
|
||||
"Cannot decline an accepted trophy",
|
||||
);
|
||||
|
||||
const declined = await TrophyRepository.declinePending({
|
||||
id: data.pendingTrophyId,
|
||||
reason: data.reason,
|
||||
declinedByUserId: user.id,
|
||||
});
|
||||
errorToastIfFalsy(declined, "Cannot decline an accepted trophy");
|
||||
|
||||
if (pending.submitterUserId !== user.id) {
|
||||
notify({
|
||||
userIds: [pending.submitterUserId],
|
||||
notification: {
|
||||
type: "TROPHY_SUBMISSION_DECLINED",
|
||||
meta: { trophyName: pending.name },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
case "APPROVE": {
|
||||
errorToastIfFalsy(canReviewTrophies(user), "Not allowed");
|
||||
|
||||
const pending = await TrophyRepository.findPendingById(
|
||||
data.pendingTrophyId,
|
||||
);
|
||||
|
||||
errorToastIfFalsy(pending, "Pending trophy not found");
|
||||
errorToastIfFalsy(
|
||||
!pending.declinedAt,
|
||||
"Cannot approve a declined trophy",
|
||||
);
|
||||
errorToastIfFalsy(
|
||||
pending.approvals.length < TROPHY_APPROVALS_REQUIRED,
|
||||
"Trophy is already accepted",
|
||||
);
|
||||
errorToastIfFalsy(
|
||||
!pending.approvals.some((a) => a.userId === user.id),
|
||||
"Already approved",
|
||||
);
|
||||
|
||||
const inserted = await TrophyRepository.addApproval({
|
||||
pendingTrophyId: data.pendingTrophyId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (inserted) {
|
||||
clearTrophiesCache();
|
||||
|
||||
if (pending.submitterUserId !== user.id) {
|
||||
notify({
|
||||
userIds: [pending.submitterUserId],
|
||||
notification: {
|
||||
type: "TROPHY_SUBMISSION_ACCEPTED",
|
||||
meta: { trophyName: pending.name, trophyId: inserted.id },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async function notifyReviewersOfSubmission({
|
||||
trophyName,
|
||||
submitter,
|
||||
}: {
|
||||
trophyName: string;
|
||||
submitter: AuthenticatedUser;
|
||||
}) {
|
||||
const reviewerIds = await UserRepository.existingUserIds(
|
||||
[ADMIN_ID, ...QA_IDS].filter((id) => id !== submitter.id),
|
||||
);
|
||||
|
||||
notify({
|
||||
userIds: reviewerIds,
|
||||
notification: {
|
||||
type: "TROPHY_SUBMITTED",
|
||||
meta: { trophyName, submitterUsername: submitter.username },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
99
app/features/trophies/components/TournamentSummaryRow.tsx
Normal file
99
app/features/trophies/components/TournamentSummaryRow.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { clsx } from "clsx";
|
||||
import { Users } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { TierPill } from "~/components/TierPill";
|
||||
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
|
||||
import { useFormatDistanceToNow } from "~/hooks/intl/useFormatDistanceToNow";
|
||||
import { useHydrated } from "~/hooks/useHydrated";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { tournamentPage } from "~/utils/urls";
|
||||
import styles from "./TournamentSummaryRow.module.css";
|
||||
|
||||
export function TournamentSummaryRow({
|
||||
tournament,
|
||||
className,
|
||||
}: {
|
||||
tournament: {
|
||||
tournamentId: number;
|
||||
name: string;
|
||||
logoUrl: string;
|
||||
teamsCount: number | null;
|
||||
tier?: number | null;
|
||||
tentativeTier?: number | null;
|
||||
startTime?: number | null;
|
||||
};
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation(["trophies"]);
|
||||
const isHydrated = useHydrated();
|
||||
const formatDistanceToNow = useFormatDistanceToNow();
|
||||
const { formatter } = useDateTimeFormat({
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const isUpcoming = Boolean(
|
||||
tournament.startTime &&
|
||||
databaseTimestampToDate(tournament.startTime) > new Date(),
|
||||
);
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={tournamentPage(tournament.tournamentId)}
|
||||
className={clsx(styles.row, className)}
|
||||
>
|
||||
<img
|
||||
src={tournament.logoUrl}
|
||||
alt=""
|
||||
width={32}
|
||||
height={32}
|
||||
className={styles.logo}
|
||||
/>
|
||||
<div className="stack xxs">
|
||||
<span className={styles.name}>
|
||||
<p>{tournament.name}</p>
|
||||
{tournament.tier ? (
|
||||
<TierPill tier={tournament.tier} />
|
||||
) : tournament.tentativeTier ? (
|
||||
<TierPill tier={tournament.tentativeTier} isTentative />
|
||||
) : null}
|
||||
{isUpcoming ? (
|
||||
<span className={styles.upcomingPill}>
|
||||
{t("trophies:details.upcoming")}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<div className={styles.meta}>
|
||||
<span className={styles.metaItem}>
|
||||
<Users className={styles.metaIcon} />
|
||||
{tournament.teamsCount}
|
||||
</span>
|
||||
{tournament.startTime ? (
|
||||
isUpcoming ? (
|
||||
<time
|
||||
className={clsx(styles.metaItem, {
|
||||
invisible: !isHydrated,
|
||||
})}
|
||||
dateTime={databaseTimestampToDate(
|
||||
tournament.startTime,
|
||||
).toISOString()}
|
||||
>
|
||||
{isHydrated
|
||||
? formatDistanceToNow(tournament.startTime, {
|
||||
addSuffix: true,
|
||||
})
|
||||
: "Placeholder"}
|
||||
</time>
|
||||
) : (
|
||||
<span className={styles.metaItem}>
|
||||
{formatter.format(tournament.startTime)}
|
||||
</span>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
54
app/features/trophies/components/TrophiesSelector.module.css
Normal file
54
app/features/trophies/components/TrophiesSelector.module.css
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
padding: var(--s-1) var(--s-2);
|
||||
border: var(--border-style);
|
||||
border-radius: var(--radius-box);
|
||||
background-color: var(--color-bg-high);
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&.isDragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.dragHandle {
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-md);
|
||||
touch-action: none;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.preview {
|
||||
width: 40px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-semi);
|
||||
}
|
||||
177
app/features/trophies/components/TrophiesSelector.tsx
Normal file
177
app/features/trophies/components/TrophiesSelector.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import type { DragEndEvent } from "@dnd-kit/core";
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import clsx from "clsx";
|
||||
import { Trash } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import styles from "./TrophiesSelector.module.css";
|
||||
import { Trophy, TrophyContextProvider } from "./Trophy";
|
||||
import type { TrophyDisplayProps } from "./TrophyDisplay";
|
||||
|
||||
type TrophyItem = TrophyDisplayProps["trophies"][number];
|
||||
|
||||
export function TrophiesSelector({
|
||||
options,
|
||||
selectedTrophies,
|
||||
onChange,
|
||||
onBlur,
|
||||
maxCount,
|
||||
}: {
|
||||
options: TrophyDisplayProps["trophies"];
|
||||
selectedTrophies: number[];
|
||||
onChange: (newTrophies: number[]) => void;
|
||||
onBlur?: () => void;
|
||||
maxCount?: number;
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
const selectedTrophiesInOrder = selectedTrophies
|
||||
.map((id) => options.find((trophy) => trophy.id === id))
|
||||
.filter((trophy): trophy is TrophyItem => trophy !== undefined);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const handleRemove = (trophyId: number) => {
|
||||
onChange(selectedTrophies.filter((id) => id !== trophyId));
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const oldIndex = selectedTrophies.indexOf(active.id as number);
|
||||
const newIndex = selectedTrophies.indexOf(over.id as number);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
|
||||
const newOrder = [...selectedTrophies];
|
||||
const [removed] = newOrder.splice(oldIndex, 1);
|
||||
newOrder.splice(newIndex, 0, removed);
|
||||
onChange(newOrder);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
{selectedTrophiesInOrder.length > 0 ? (
|
||||
<TrophyContextProvider>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={selectedTrophiesInOrder.map((trophy) => trophy.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<ul className={styles.list}>
|
||||
{selectedTrophiesInOrder.map((trophy) => (
|
||||
<SortableTrophyItem
|
||||
key={trophy.id}
|
||||
trophy={trophy}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</TrophyContextProvider>
|
||||
) : (
|
||||
<div className="text-lighter text-md font-bold">
|
||||
{t("common:trophies.selector.none")}
|
||||
</div>
|
||||
)}
|
||||
{options.length === 0 ? (
|
||||
<div className="text-warning text-xs">
|
||||
{t("common:trophies.selector.noneAvailable")}
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
onBlur={() => onBlur?.()}
|
||||
onChange={(e) =>
|
||||
onChange([...selectedTrophies, Number(e.target.value)])
|
||||
}
|
||||
disabled={Boolean(maxCount && selectedTrophies.length >= maxCount)}
|
||||
data-testid="trophies-selector"
|
||||
>
|
||||
<option>{t("common:trophies.selector.select")}</option>
|
||||
{options
|
||||
.filter((trophy) => !selectedTrophies.includes(trophy.id))
|
||||
.map((trophy) => (
|
||||
<option key={trophy.id} value={trophy.id}>
|
||||
{trophy.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SortableTrophyItem({
|
||||
trophy,
|
||||
onRemove,
|
||||
}: {
|
||||
trophy: TrophyItem;
|
||||
onRemove: (id: number) => void;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: trophy.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={clsx(styles.item, { [styles.isDragging]: isDragging })}
|
||||
{...attributes}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.dragHandle}
|
||||
aria-label="Drag to reorder"
|
||||
{...listeners}
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<div className={styles.preview}>
|
||||
<Trophy model={trophy.model} preview />
|
||||
</div>
|
||||
<span className={styles.name}>{trophy.name}</span>
|
||||
<SendouButton
|
||||
variant="minimal-destructive"
|
||||
size="small"
|
||||
icon={<Trash />}
|
||||
aria-label="Remove"
|
||||
onPress={() => onRemove(trophy.id)}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
73
app/features/trophies/components/Trophy.module.css
Normal file
73
app/features/trophies/components/Trophy.module.css
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
.container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tile {
|
||||
border: var(--border-width) solid var(--tier-bg);
|
||||
border-radius: var(--radius-field);
|
||||
background-color: var(--color-bg-high);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, 100px);
|
||||
justify-content: center;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.tierPill {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
right: -1px;
|
||||
|
||||
> * {
|
||||
border-radius: 0 var(--radius-field) 0 var(--radius-field);
|
||||
}
|
||||
}
|
||||
|
||||
.cornerPill {
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: -1px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: var(--selector-size-sm);
|
||||
padding: 0 var(--s-1-5);
|
||||
border-radius: 0 var(--radius-field) 0 var(--radius-field);
|
||||
background-color: var(--tier-bg, var(--color-bg-higher));
|
||||
color: var(--tier-text, var(--color-text-high));
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
.trophy {
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
aspect-ratio: 1 / 1;
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.interactive {
|
||||
cursor: grab;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--color-text-high);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
255
app/features/trophies/components/Trophy.tsx
Normal file
255
app/features/trophies/components/Trophy.tsx
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import { clsx } from "clsx";
|
||||
import { Ban } from "lucide-react";
|
||||
import { PicoCAD2Context, PicoCAD2Viewer } from "picocad2-web";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { TierPill } from "~/components/TierPill";
|
||||
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
|
||||
import { decompressTrophyModel } from "../trophies-utils";
|
||||
import style from "./Trophy.module.css";
|
||||
|
||||
type TrophyCtxValue =
|
||||
| { context: PicoCAD2Context }
|
||||
| { context: undefined; isLoading: true };
|
||||
|
||||
const TrophyCtx = createContext<TrophyCtxValue | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Shares one PicoCAD2 WebGL context across every `Trophy` rendered inside.
|
||||
*
|
||||
* `Trophy` falls back to creating its own internal context when no provider is
|
||||
* used, but browsers cap active WebGL contexts at 16 so any grid bigger than
|
||||
* that, or rapid mounting/unmounting, triggers a warning and broken rendering.
|
||||
*
|
||||
* We use two implementations to stay under the limit:
|
||||
*
|
||||
* 1. One page wide `PicoCAD2Context` singleton.
|
||||
* Creating a new one per mount can stack contexts faster than the browser
|
||||
* can remove them. Holding one for the page lifetime is cheaper in comparison.
|
||||
*
|
||||
* 2. The provider always renders its children so surrounding layout doesn't shift
|
||||
* but uses an additional `isLoading` bool while `useState` is still `undefined`
|
||||
* before the first effect fires. Descendant `Trophy` components read the bool
|
||||
* and render an empty spacer instead of mounting a canvas that would create
|
||||
* their own internal context.
|
||||
*/
|
||||
|
||||
let sharedContext: PicoCAD2Context | undefined;
|
||||
|
||||
function getSharedTrophyContext() {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
if (!sharedContext) sharedContext = new PicoCAD2Context();
|
||||
return sharedContext;
|
||||
}
|
||||
|
||||
export function TrophyContextProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [context, setContext] = useState<PicoCAD2Context | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
setContext(getSharedTrophyContext());
|
||||
}, []);
|
||||
|
||||
const value: TrophyCtxValue = context
|
||||
? { context }
|
||||
: { context: undefined, isLoading: true };
|
||||
|
||||
return <TrophyCtx.Provider value={value}>{children}</TrophyCtx.Provider>;
|
||||
}
|
||||
|
||||
export function TrophyGrid({ children }: { children: React.ReactNode }) {
|
||||
return <div className={style.grid}>{children}</div>;
|
||||
}
|
||||
|
||||
export function TrophyPlaceholder() {
|
||||
return <div className={style.placeholder} />;
|
||||
}
|
||||
|
||||
export function Trophy({
|
||||
model,
|
||||
className,
|
||||
preview,
|
||||
tile,
|
||||
tier,
|
||||
tentativeTier,
|
||||
disableCameraControls,
|
||||
staticOnSoftwareRendering,
|
||||
pill,
|
||||
}: {
|
||||
model: string;
|
||||
className?: string;
|
||||
preview?: boolean;
|
||||
tile?: boolean;
|
||||
tier?: number | null;
|
||||
tentativeTier?: number | null;
|
||||
disableCameraControls?: boolean;
|
||||
staticOnSoftwareRendering?: boolean;
|
||||
pill?: React.ReactNode;
|
||||
}) {
|
||||
const ctxValue = useContext(TrophyCtx);
|
||||
const context = ctxValue?.context;
|
||||
const isLoadingSharedContext =
|
||||
ctxValue !== undefined && ctxValue.context === undefined;
|
||||
const viewerRef = useRef<PicoCAD2Viewer | null>(null);
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
|
||||
const prevModelRef = useRef(model);
|
||||
if (prevModelRef.current !== model) {
|
||||
prevModelRef.current = model;
|
||||
setError(false);
|
||||
}
|
||||
|
||||
const modelState = decompressTrophyModel(model);
|
||||
|
||||
// useCallback is needed to keep the ref callback identity stable.
|
||||
// A new identity each render would make React detach and re-attach,
|
||||
// disposing and rebuilding the viewer on every re-render
|
||||
const canvasRef = useCallback(
|
||||
(canvas: HTMLCanvasElement | null) => {
|
||||
if (!canvas) {
|
||||
viewerRef.current?.dispose();
|
||||
viewerRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (modelState === null) return;
|
||||
|
||||
const viewer = new PicoCAD2Viewer({
|
||||
canvas,
|
||||
context,
|
||||
resolution: { width: 128, height: 128, scale: 4 },
|
||||
});
|
||||
viewerRef.current = viewer;
|
||||
|
||||
try {
|
||||
viewer.setState(JSON.parse(modelState));
|
||||
} catch (_) {
|
||||
setError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// continuous render loops starve the main thread when WebGL is
|
||||
// software rendered, so e2e runs (which always render on CPU) draw a
|
||||
// single static frame, as do surfaces showing many loops at once on
|
||||
// devices without GPU acceleration
|
||||
if (
|
||||
preview ||
|
||||
IS_E2E_TEST_RUN ||
|
||||
(staticOnSoftwareRendering && isSoftwareRendering())
|
||||
) {
|
||||
viewer.draw();
|
||||
viewer.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
viewer.cameraMode = "spin";
|
||||
viewer.cameraModeSpeed = 5;
|
||||
viewer.startRenderLoop(false);
|
||||
|
||||
if (disableCameraControls) return;
|
||||
|
||||
viewer.enableCameraControls({
|
||||
spinInertiaFactor: 0.95,
|
||||
pan: false,
|
||||
rotate: true,
|
||||
zoom: true,
|
||||
useFixedOnInteract: {
|
||||
enabled: true,
|
||||
delayBeforeRestore: 1000,
|
||||
restoreTime: 1000,
|
||||
},
|
||||
});
|
||||
},
|
||||
[
|
||||
modelState,
|
||||
context,
|
||||
preview,
|
||||
staticOnSoftwareRendering,
|
||||
disableCameraControls,
|
||||
],
|
||||
);
|
||||
|
||||
const effectiveTier = tier ?? tentativeTier ?? null;
|
||||
const containerClassName = clsx(style.container, className, {
|
||||
[style.tile]: tile,
|
||||
});
|
||||
const containerStyle = effectiveTier
|
||||
? ({
|
||||
"--tier-bg": `var(--tier-bg-${effectiveTier})`,
|
||||
"--tier-text": `var(--tier-text-${effectiveTier})`,
|
||||
} as React.CSSProperties)
|
||||
: undefined;
|
||||
|
||||
const tierPill = tier ? (
|
||||
<div className={style.tierPill}>
|
||||
<TierPill tier={tier} />
|
||||
</div>
|
||||
) : tentativeTier ? (
|
||||
<div className={style.tierPill}>
|
||||
<TierPill tier={tentativeTier} isTentative />
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const cornerPill = pill ? (
|
||||
<div className={style.cornerPill} data-testid="trophy-corner-pill">
|
||||
{pill}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
if (error || modelState === null) {
|
||||
return (
|
||||
<div className={containerClassName} style={containerStyle}>
|
||||
<div className={clsx(style.trophy, style.error)}>
|
||||
<Ban size={48} />
|
||||
</div>
|
||||
{tierPill}
|
||||
{cornerPill}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoadingSharedContext) {
|
||||
return (
|
||||
<div className={containerClassName} style={containerStyle}>
|
||||
<div className={style.trophy} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={containerClassName} style={containerStyle}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={clsx(style.trophy, {
|
||||
[style.interactive]: !preview && !disableCameraControls,
|
||||
})}
|
||||
/>
|
||||
{tierPill}
|
||||
{cornerPill}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let softwareRenderingDetected: boolean | undefined;
|
||||
|
||||
function isSoftwareRendering() {
|
||||
if (softwareRenderingDetected !== undefined) return softwareRenderingDetected;
|
||||
if (typeof document === "undefined") return false;
|
||||
|
||||
const gl = document
|
||||
.createElement("canvas")
|
||||
.getContext("webgl2", { failIfMajorPerformanceCaveat: true });
|
||||
softwareRenderingDetected = !gl;
|
||||
gl?.getExtension("WEBGL_lose_context")?.loseContext();
|
||||
|
||||
return softwareRenderingDetected;
|
||||
}
|
||||
112
app/features/trophies/components/TrophyDisplay.module.css
Normal file
112
app/features/trophies/components/TrophyDisplay.module.css
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-inline: auto;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 100px);
|
||||
justify-content: center;
|
||||
gap: var(--s-2);
|
||||
padding: var(--s-2);
|
||||
border: var(--border-style);
|
||||
border-radius: var(--radius-box);
|
||||
}
|
||||
|
||||
.trophyButton {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.win {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1-5);
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.winHeader {
|
||||
margin-top: var(--s-2);
|
||||
}
|
||||
|
||||
.winDetails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
padding-inline: var(--s-2);
|
||||
}
|
||||
|
||||
.winMembers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.winMember {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr max-content;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
font-size: var(--font-xs);
|
||||
border-radius: var(--radius-field);
|
||||
padding: var(--s-1);
|
||||
|
||||
& > * {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
&:nth-child(odd) {
|
||||
background-color: var(--color-bg-high);
|
||||
}
|
||||
}
|
||||
|
||||
@container (width >= 560px) and (width <= 750px) {
|
||||
.winMember {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
justify-items: center;
|
||||
|
||||
& > button {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.winMemberUser {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1-5);
|
||||
font-weight: var(--weight-semi);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.winMemberName {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.winMemberWeapons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.specialDescription {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-text-high);
|
||||
padding-top: var(--s-2);
|
||||
}
|
||||
211
app/features/trophies/components/TrophyDisplay.tsx
Normal file
211
app/features/trophies/components/TrophyDisplay.tsx
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFetcher } from "react-router";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { Divider } from "~/components/Divider";
|
||||
import { DotPagination } from "~/components/DotPagination";
|
||||
import { WeaponImage } from "~/components/Image";
|
||||
import { UserCard } from "~/features/user-card/components/UserCard";
|
||||
import { ParticipationPill } from "~/features/user-page/components/ParticipationPill";
|
||||
import { usePagination } from "~/hooks/usePagination";
|
||||
import { trophyWinsPage } from "~/utils/urls";
|
||||
import type { TrophyWinsLoaderData } from "../routes/trophies.$id.wins.$userId";
|
||||
import { SMALL_TROPHIES_PER_DISPLAY_PAGE } from "../trophies-constants";
|
||||
import {
|
||||
parseSpecialTrophyCode,
|
||||
useProgressiveRender,
|
||||
} from "../trophies-utils";
|
||||
import { TournamentSummaryRow } from "./TournamentSummaryRow";
|
||||
import { Trophy, TrophyContextProvider, TrophyPlaceholder } from "./Trophy";
|
||||
import styles from "./TrophyDisplay.module.css";
|
||||
import { TrophyShowcaseModal } from "./TrophyShowcase";
|
||||
|
||||
type TrophyItem = {
|
||||
id: number;
|
||||
name: string;
|
||||
model: string;
|
||||
tier?: number | null;
|
||||
code?: string | null;
|
||||
count?: number | null;
|
||||
};
|
||||
|
||||
export interface TrophyDisplayProps {
|
||||
trophies: Array<TrophyItem>;
|
||||
userId: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TrophyDisplay({
|
||||
trophies,
|
||||
userId,
|
||||
className,
|
||||
}: TrophyDisplayProps) {
|
||||
const [openTrophy, setOpenTrophy] = React.useState<TrophyItem | null>(null);
|
||||
|
||||
const {
|
||||
itemsToDisplay,
|
||||
everythingVisible,
|
||||
currentPage,
|
||||
pagesCount,
|
||||
setPage,
|
||||
} = usePagination({
|
||||
items: trophies,
|
||||
pageSize: SMALL_TROPHIES_PER_DISPLAY_PAGE,
|
||||
scrollToTop: false,
|
||||
});
|
||||
|
||||
const visibleCount = useProgressiveRender(
|
||||
itemsToDisplay.length,
|
||||
String(currentPage),
|
||||
);
|
||||
|
||||
if (trophies.length === 0) return null;
|
||||
|
||||
return (
|
||||
<TrophyContextProvider>
|
||||
<div
|
||||
data-testid="trophy-display"
|
||||
className={clsx(className, styles.root)}
|
||||
>
|
||||
<div className={styles.grid}>
|
||||
{itemsToDisplay.map((trophy, i) =>
|
||||
i < visibleCount ? (
|
||||
<button
|
||||
key={trophy.id}
|
||||
type="button"
|
||||
className={styles.trophyButton}
|
||||
onClick={() => setOpenTrophy(trophy)}
|
||||
aria-label={trophy.name}
|
||||
>
|
||||
<Trophy
|
||||
tile
|
||||
model={trophy.model}
|
||||
tier={trophy.tier ?? null}
|
||||
preview={!!openTrophy}
|
||||
staticOnSoftwareRendering
|
||||
disableCameraControls
|
||||
pill={
|
||||
trophy.count && trophy.count > 1
|
||||
? `×${trophy.count}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<TrophyPlaceholder key={trophy.id} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
{!everythingVisible ? (
|
||||
<DotPagination
|
||||
pagesCount={pagesCount}
|
||||
currentPage={currentPage}
|
||||
setPage={setPage}
|
||||
ariaLabelPrefix="Trophies"
|
||||
data-testid="trophy-pagination-button"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{openTrophy ? (
|
||||
<TrophyModal
|
||||
trophy={openTrophy}
|
||||
userId={userId}
|
||||
onClose={() => setOpenTrophy(null)}
|
||||
/>
|
||||
) : null}
|
||||
</TrophyContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TrophyModal({
|
||||
trophy,
|
||||
userId,
|
||||
onClose,
|
||||
}: {
|
||||
trophy: TrophyItem;
|
||||
userId: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["trophies"]);
|
||||
const fetcher = useFetcher<TrophyWinsLoaderData>();
|
||||
const data = fetcher.data;
|
||||
|
||||
const special = parseSpecialTrophyCode(trophy.code);
|
||||
|
||||
const loadedRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
if (parseSpecialTrophyCode(trophy.code) || loadedRef.current) return;
|
||||
loadedRef.current = true;
|
||||
fetcher.load(trophyWinsPage({ trophyId: trophy.id, userId }));
|
||||
}, [fetcher.load, trophy.id, trophy.code, userId]);
|
||||
|
||||
return (
|
||||
<TrophyShowcaseModal trophy={trophy} onClose={onClose}>
|
||||
{special ? (
|
||||
<div>
|
||||
<Divider />
|
||||
<p className={styles.specialDescription}>
|
||||
{special.type === "supporter"
|
||||
? t("trophies:special.supporter.description")
|
||||
: t("trophies:special.xp.description", {
|
||||
value: special.value,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{data
|
||||
? data.wins.map((win) => (
|
||||
<div key={win.tournamentId}>
|
||||
<Divider />
|
||||
<TrophyWinDetails win={win} userCards={data.userCards} />
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
</TrophyShowcaseModal>
|
||||
);
|
||||
}
|
||||
|
||||
function TrophyWinDetails({
|
||||
win,
|
||||
userCards,
|
||||
}: {
|
||||
win: TrophyWinsLoaderData["wins"][number];
|
||||
userCards: TrophyWinsLoaderData["userCards"];
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.win}>
|
||||
<TournamentSummaryRow tournament={win} className={styles.winHeader} />
|
||||
<div className={styles.winDetails}>
|
||||
{win.members.length > 0 ? (
|
||||
<div className={styles.winMembers}>
|
||||
{win.members.map((member) => (
|
||||
<div key={member.id} className={styles.winMember}>
|
||||
<UserCard data={userCards.get(member.id)}>
|
||||
<span className={styles.winMemberUser}>
|
||||
<Avatar size="xxxs" user={member} />
|
||||
<span className={styles.winMemberName}>
|
||||
{member.username}
|
||||
</span>
|
||||
</span>
|
||||
</UserCard>
|
||||
<span className={styles.winMemberWeapons}>
|
||||
{member.weapons.map((weaponSplId) => (
|
||||
<WeaponImage
|
||||
key={weaponSplId}
|
||||
weaponSplId={weaponSplId}
|
||||
variant="badge"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<ParticipationPill setResults={member.setResults} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
app/features/trophies/components/TrophyShowcase.module.css
Normal file
47
app/features/trophies/components/TrophyShowcase.module.css
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
.wrapper {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.modal {
|
||||
padding: 0;
|
||||
max-width: 48rem;
|
||||
}
|
||||
|
||||
.trophyName {
|
||||
font-weight: var(--weight-bold);
|
||||
font-size: var(--font-lg);
|
||||
}
|
||||
|
||||
.trophyPageLink {
|
||||
font-size: var(--font-2xs);
|
||||
margin-bottom: var(--s-2);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
align-items: center;
|
||||
background-color: var(--color-bg-high);
|
||||
}
|
||||
|
||||
.details {
|
||||
padding: var(--s-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
background-color: var(--color-bg);
|
||||
border-top: var(--border-style);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@container (width >= 560px) {
|
||||
.content {
|
||||
grid-template-columns: 60% 1fr;
|
||||
}
|
||||
|
||||
.details {
|
||||
border-top: 0;
|
||||
border-left: var(--border-style);
|
||||
height: 60cqw;
|
||||
}
|
||||
}
|
||||
63
app/features/trophies/components/TrophyShowcase.tsx
Normal file
63
app/features/trophies/components/TrophyShowcase.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import clsx from "clsx";
|
||||
import type * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { trophyPage } from "~/utils/urls";
|
||||
import { Trophy } from "./Trophy";
|
||||
import styles from "./TrophyShowcase.module.css";
|
||||
|
||||
export function TrophyShowcase({
|
||||
model,
|
||||
children,
|
||||
className,
|
||||
detailsClassName,
|
||||
}: {
|
||||
model: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
detailsClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={clsx(styles.content, className)}>
|
||||
<Trophy model={model} />
|
||||
<div className={clsx(styles.details, detailsClassName, "scrollbar")}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrophyShowcaseModal({
|
||||
trophy,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
trophy: { id: number; name: string; model: string };
|
||||
onClose: () => void;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation(["trophies"]);
|
||||
|
||||
return (
|
||||
<SendouDialog
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
showHeading={false}
|
||||
isDismissable
|
||||
className={styles.modal}
|
||||
>
|
||||
<TrophyShowcase model={trophy.model}>
|
||||
<div className="stack xxs">
|
||||
<p className={styles.trophyName}>{trophy.name}</p>
|
||||
<Link to={trophyPage(trophy.id)} className={styles.trophyPageLink}>
|
||||
{t("trophies:display.viewTrophyPage")}
|
||||
</Link>
|
||||
</div>
|
||||
{children}
|
||||
</TrophyShowcase>
|
||||
</SendouDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
gap: var(--s-1);
|
||||
|
||||
& > li {
|
||||
list-style: none;
|
||||
}
|
||||
}
|
||||
19
app/features/trophies/components/TrophyTournamentHistory.tsx
Normal file
19
app/features/trophies/components/TrophyTournamentHistory.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { TrophyTournamentsLoaderData } from "../routes/trophies.$id.tournaments";
|
||||
import { TournamentSummaryRow } from "./TournamentSummaryRow";
|
||||
import styles from "./TrophyTournamentHistory.module.css";
|
||||
|
||||
export function TrophyTournamentHistory({
|
||||
tournaments,
|
||||
}: {
|
||||
tournaments: TrophyTournamentsLoaderData["tournaments"];
|
||||
}) {
|
||||
return (
|
||||
<ul className={styles.list}>
|
||||
{tournaments.map((tournament) => (
|
||||
<li key={tournament.tournamentId}>
|
||||
<TournamentSummaryRow tournament={tournament} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
110
app/features/trophies/core/model-analysis.ts
Normal file
110
app/features/trophies/core/model-analysis.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import type { Color3, ExtrasOptions, ViewerSettings } from "picocad2-web";
|
||||
|
||||
export interface TrophyModelAnalysis {
|
||||
cameraTargetCentered: boolean;
|
||||
backgroundIsAlpha: boolean;
|
||||
drawCalls: number;
|
||||
polyCount: number;
|
||||
effectsCount: number;
|
||||
}
|
||||
|
||||
type RawColor = Array<number> | { r: number; g: number; b: number };
|
||||
|
||||
interface RawFace {
|
||||
vertex_ids: Array<number>;
|
||||
dbl?: boolean;
|
||||
prio?: boolean;
|
||||
}
|
||||
|
||||
interface RawGraphNode {
|
||||
visible: boolean;
|
||||
children: Array<RawGraphNode>;
|
||||
mesh?: { faces: Array<RawFace> };
|
||||
}
|
||||
|
||||
interface ModelState {
|
||||
source: {
|
||||
graph: RawGraphNode;
|
||||
texture: {
|
||||
colors: Array<RawColor>;
|
||||
background_color: number;
|
||||
transparent_color: number;
|
||||
};
|
||||
};
|
||||
settings: ViewerSettings;
|
||||
extras?: ExtrasOptions;
|
||||
}
|
||||
|
||||
export function analyzeTrophyModel(model: string): TrophyModelAnalysis | null {
|
||||
try {
|
||||
const state: ModelState = JSON.parse(model);
|
||||
|
||||
return {
|
||||
cameraTargetCentered: isCameraTargetCentered(state),
|
||||
backgroundIsAlpha: isBackgroundAlpha(state),
|
||||
...countGeometry(state),
|
||||
effectsCount: countEnabledEffects(state),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isCameraTargetCentered(state: ModelState) {
|
||||
const target = state.settings.camera.target;
|
||||
return target[0] === 0 && target[2] === 0;
|
||||
}
|
||||
|
||||
function isBackgroundAlpha(state: ModelState) {
|
||||
const texture = state.source.texture;
|
||||
const transparent = toColor3(texture.colors[texture.transparent_color]);
|
||||
const background =
|
||||
state.settings.backgroundColor ??
|
||||
toColor3(texture.colors[texture.background_color]);
|
||||
|
||||
return (
|
||||
background[0] === transparent[0] &&
|
||||
background[1] === transparent[1] &&
|
||||
background[2] === transparent[2]
|
||||
);
|
||||
}
|
||||
|
||||
function toColor3(color: RawColor): Color3 {
|
||||
return Array.isArray(color)
|
||||
? [color[0], color[1], color[2]]
|
||||
: [color.r, color.g, color.b];
|
||||
}
|
||||
|
||||
function countGeometry(state: ModelState) {
|
||||
const counts = { drawCalls: 0, polyCount: 0 };
|
||||
|
||||
const visit = (node: RawGraphNode, parentVisible: boolean) => {
|
||||
for (const child of node.children) {
|
||||
const visible = Boolean(child.visible) && parentVisible;
|
||||
|
||||
if (visible && child.mesh) {
|
||||
const groups = new Set<number>();
|
||||
|
||||
for (const face of child.mesh.faces) {
|
||||
if (face.vertex_ids.length < 3) continue;
|
||||
|
||||
groups.add((face.dbl ? 1 : 0) | (face.prio ? 2 : 0));
|
||||
counts.polyCount += face.vertex_ids.length - 2;
|
||||
}
|
||||
|
||||
counts.drawCalls += groups.size;
|
||||
}
|
||||
|
||||
visit(child, visible);
|
||||
}
|
||||
};
|
||||
visit(state.source.graph, true);
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
function countEnabledEffects(state: ModelState) {
|
||||
return Object.values(state.extras ?? {}).filter(
|
||||
(effect) => effect?.enabled === true,
|
||||
).length;
|
||||
}
|
||||
27
app/features/trophies/loaders/trophies.$id.server.ts
Normal file
27
app/features/trophies/loaders/trophies.$id.server.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
|
||||
import { idObject } from "~/utils/zod";
|
||||
import * as TrophyRepository from "../TrophyRepository.server";
|
||||
import { canAccessTrophies } from "../trophies-utils";
|
||||
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
if (!canAccessTrophies(getUser())) {
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
const { id } = parseParams({
|
||||
params,
|
||||
schema: idObject,
|
||||
});
|
||||
|
||||
const [trophy, tournaments] = await Promise.all([
|
||||
TrophyRepository.findById(id).then(notFoundIfNullish),
|
||||
TrophyRepository.findTournamentsByTrophyId(id),
|
||||
]);
|
||||
|
||||
return {
|
||||
trophy,
|
||||
tournaments,
|
||||
};
|
||||
};
|
||||
67
app/features/trophies/loaders/trophies.new.server.ts
Normal file
67
app/features/trophies/loaders/trophies.new.server.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import * as TrophyRepository from "../TrophyRepository.server";
|
||||
import { TROPHY_APPROVALS_REQUIRED } from "../trophies-constants";
|
||||
import {
|
||||
canAccessTrophies,
|
||||
canEditAnyTrophy,
|
||||
canReviewTrophies,
|
||||
} from "../trophies-utils";
|
||||
|
||||
export type NewTrophyLoaderData = SerializeFrom<typeof loader>;
|
||||
|
||||
export const loader = async (_args: LoaderFunctionArgs) => {
|
||||
const user = requireUser();
|
||||
if (!canAccessTrophies(user)) {
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
const canReview = canReviewTrophies(user);
|
||||
|
||||
const [rawItems, ownUnreviewedCount, editableTrophies] = await Promise.all([
|
||||
canReview
|
||||
? TrophyRepository.allPending()
|
||||
: TrophyRepository.pendingBySubmitter(user.id),
|
||||
TrophyRepository.unreviewedCountBySubmitter(user.id),
|
||||
canEditAnyTrophy(user)
|
||||
? TrophyRepository.findAllForEditing()
|
||||
: TrophyRepository.findManagedBy(user.id),
|
||||
]);
|
||||
|
||||
const allItems = canReview ? rawItems : rawItems.map(stripReviewerInfo);
|
||||
|
||||
const isAccepted = (item: (typeof allItems)[number]) =>
|
||||
item.approvals.length >= TROPHY_APPROVALS_REQUIRED;
|
||||
|
||||
const pendingTrophies = allItems.filter(
|
||||
(item) => !isAccepted(item) && !item.declinedAt,
|
||||
);
|
||||
const reviewedTrophies = allItems.filter(
|
||||
(item) => isAccepted(item) || item.declinedAt,
|
||||
);
|
||||
|
||||
return {
|
||||
canReview,
|
||||
currentUserId: user.id,
|
||||
ownUnreviewedCount,
|
||||
pendingTrophies,
|
||||
reviewedTrophies,
|
||||
editableTrophies,
|
||||
};
|
||||
};
|
||||
|
||||
function stripReviewerInfo(
|
||||
item: Awaited<ReturnType<typeof TrophyRepository.allPending>>[number],
|
||||
) {
|
||||
return {
|
||||
...item,
|
||||
approvals: item.approvals.map(() => ({
|
||||
userId: null as number | null,
|
||||
username: null as string | null,
|
||||
createdAt: 0,
|
||||
})),
|
||||
declinedByUserId: null,
|
||||
declinedByUsername: null,
|
||||
};
|
||||
}
|
||||
28
app/features/trophies/loaders/trophies.server.ts
Normal file
28
app/features/trophies/loaders/trophies.server.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { cachified } from "@epic-web/cachified";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server";
|
||||
import * as TrophyRepository from "../TrophyRepository.server";
|
||||
import { canAccessTrophies } from "../trophies-utils";
|
||||
|
||||
const TROPHIES_CACHE_KEY = "trophies";
|
||||
|
||||
export const loader = async () => {
|
||||
if (!canAccessTrophies(getUser())) {
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
const trophies = await cachified({
|
||||
key: TROPHIES_CACHE_KEY,
|
||||
cache,
|
||||
ttl: ttl(IN_MILLISECONDS.TWO_HOURS),
|
||||
async getFreshValue() {
|
||||
return TrophyRepository.all();
|
||||
},
|
||||
});
|
||||
|
||||
return { trophies };
|
||||
};
|
||||
|
||||
export function clearTrophiesCache() {
|
||||
cache.delete(TROPHIES_CACHE_KEY);
|
||||
}
|
||||
24
app/features/trophies/routes/trophies.$id.tournaments.ts
Normal file
24
app/features/trophies/routes/trophies.$id.tournaments.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import { parseParams } from "~/utils/remix.server";
|
||||
import { idObject } from "~/utils/zod";
|
||||
import * as TrophyRepository from "../TrophyRepository.server";
|
||||
import { canAccessTrophies } from "../trophies-utils";
|
||||
|
||||
export type TrophyTournamentsLoaderData = SerializeFrom<typeof loader>;
|
||||
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
if (!canAccessTrophies(getUser())) {
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
const { id } = parseParams({
|
||||
params,
|
||||
schema: idObject,
|
||||
});
|
||||
|
||||
return {
|
||||
tournaments: await TrophyRepository.findTournamentsByTrophyId(id),
|
||||
};
|
||||
};
|
||||
145
app/features/trophies/routes/trophies.$id.tsx
Normal file
145
app/features/trophies/routes/trophies.$id.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import clsx from "clsx";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Link, type MetaFunction, useLoaderData } from "react-router";
|
||||
import { Divider } from "~/components/Divider";
|
||||
import { metaTags, type SerializeFrom } from "~/utils/remix";
|
||||
import { tournamentOrganizationPage, userPage } from "~/utils/urls";
|
||||
import { TrophyShowcase } from "../components/TrophyShowcase";
|
||||
import { TrophyTournamentHistory } from "../components/TrophyTournamentHistory";
|
||||
import { loader } from "../loaders/trophies.$id.server";
|
||||
import { parseSpecialTrophyCode } from "../trophies-utils";
|
||||
import styles from "./trophies.module.css";
|
||||
|
||||
export { loader };
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
const data = args.loaderData as SerializeFrom<typeof loader> | null;
|
||||
|
||||
if (!data) return [];
|
||||
|
||||
const ownerCount = data.trophy.owners.reduce(
|
||||
(sum, owner) => sum + owner.count,
|
||||
0,
|
||||
);
|
||||
|
||||
return metaTags({
|
||||
title: data.trophy.name,
|
||||
ogTitle: `${data.trophy.name} (Splatoon trophy)`,
|
||||
description: `See who owns the ${data.trophy.name} trophy on sendou.ink. Awarded ${ownerCount} time${ownerCount === 1 ? "" : "s"} so far.`,
|
||||
location: args.location,
|
||||
});
|
||||
};
|
||||
|
||||
export default function TrophyDetailsPage() {
|
||||
const { t } = useTranslation(["trophies"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const { trophy, tournaments } = data;
|
||||
|
||||
const special = parseSpecialTrophyCode(trophy.code);
|
||||
|
||||
return (
|
||||
<TrophyShowcase
|
||||
model={trophy.model}
|
||||
className={styles.trophyDetailsContainer}
|
||||
detailsClassName={styles.trophyDetails}
|
||||
>
|
||||
<div className="stack xxs">
|
||||
<p className={styles.trophyName}>{trophy.name}</p>
|
||||
{trophy.organization ? (
|
||||
<p className={styles.trophyMeta}>
|
||||
<Trans
|
||||
t={t}
|
||||
i18nKey="trophies:details.organization"
|
||||
values={{ name: trophy.organization.name }}
|
||||
>
|
||||
Organization:
|
||||
<Link
|
||||
to={tournamentOrganizationPage({
|
||||
organizationSlug: trophy.organization.slug,
|
||||
})}
|
||||
>
|
||||
{trophy.organization.name}
|
||||
</Link>
|
||||
</Trans>
|
||||
</p>
|
||||
) : null}
|
||||
{trophy.creator ? (
|
||||
<p className={styles.trophyMeta}>
|
||||
<Trans
|
||||
t={t}
|
||||
i18nKey="trophies:details.createdBy"
|
||||
values={{ name: trophy.creator.username }}
|
||||
>
|
||||
Created by
|
||||
<Link to={userPage(trophy.creator)}>
|
||||
{trophy.creator.username}
|
||||
</Link>
|
||||
</Trans>
|
||||
</p>
|
||||
) : null}
|
||||
{trophy.manager ? (
|
||||
<p className={styles.trophyMeta}>
|
||||
<Trans
|
||||
t={t}
|
||||
i18nKey="trophies:details.managedBy"
|
||||
values={{ name: trophy.manager.username }}
|
||||
>
|
||||
Managed by
|
||||
<Link to={userPage(trophy.manager)}>
|
||||
{trophy.manager.username}
|
||||
</Link>
|
||||
</Trans>
|
||||
</p>
|
||||
) : null}
|
||||
{special ? (
|
||||
<p className={styles.trophyMeta}>
|
||||
{special.type === "supporter"
|
||||
? t("trophies:special.supporter.description")
|
||||
: t("trophies:special.xp.description", {
|
||||
value: special.value,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{special ? null : (
|
||||
<div className="stack xs">
|
||||
<Divider className={styles.divider} smallText>
|
||||
{t("trophies:details.tournamentHistory")}
|
||||
</Divider>
|
||||
{tournaments.length > 0 ? (
|
||||
<TrophyTournamentHistory tournaments={tournaments} />
|
||||
) : (
|
||||
<p className="text-center text-lighter text-xxs">
|
||||
{t("trophies:details.noTournamentHistory")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="stack xs">
|
||||
<Divider className={styles.divider} smallText>
|
||||
{t("trophies:details.owners")}
|
||||
</Divider>
|
||||
{trophy.owners.length > 0 ? (
|
||||
<ul className={styles.owners}>
|
||||
{trophy.owners.map((owner) => (
|
||||
<li key={owner.id}>
|
||||
<Link to={userPage(owner)}>{owner.username}</Link>
|
||||
<span
|
||||
className={clsx(styles.ownerCount, {
|
||||
hidden: owner.count <= 1,
|
||||
})}
|
||||
>
|
||||
×{owner.count}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-center text-lighter text-xxs">
|
||||
{t("trophies:details.noOwners")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TrophyShowcase>
|
||||
);
|
||||
}
|
||||
33
app/features/trophies/routes/trophies.$id.wins.$userId.ts
Normal file
33
app/features/trophies/routes/trophies.$id.wins.$userId.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import * as R from "remeda";
|
||||
import { z } from "zod";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import { parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
import * as TrophyRepository from "../TrophyRepository.server";
|
||||
import { canAccessTrophies } from "../trophies-utils";
|
||||
|
||||
export type TrophyWinsLoaderData = SerializeFrom<typeof loader>;
|
||||
|
||||
const paramsSchema = z.object({ id, userId: id });
|
||||
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
if (!canAccessTrophies(getUser())) {
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
const { id: trophyId, userId } = parseParams({
|
||||
params,
|
||||
schema: paramsSchema,
|
||||
});
|
||||
|
||||
const wins = await TrophyRepository.findWinsByOwner({ trophyId, userId });
|
||||
|
||||
const { userCards } = await UserCardRepository.findAllByUserIds({
|
||||
userIds: R.unique(wins.flatMap((win) => win.members.map((m) => m.id))),
|
||||
});
|
||||
|
||||
return { wins, userCards };
|
||||
};
|
||||
64
app/features/trophies/routes/trophies.module.css
Normal file
64
app/features/trophies/routes/trophies.module.css
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
.mainContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-6);
|
||||
}
|
||||
|
||||
.badgesLink {
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
.trophiesListContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-4);
|
||||
}
|
||||
|
||||
.trophyDetailsContainer {
|
||||
border: var(--border-style);
|
||||
border-radius: var(--radius-box);
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
@container (width < 560px) {
|
||||
.trophyDetails {
|
||||
height: 512px;
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin-top: var(--s-1);
|
||||
}
|
||||
|
||||
.trophyName {
|
||||
font-weight: var(--weight-bold);
|
||||
font-size: var(--font-lg);
|
||||
}
|
||||
|
||||
.trophyMeta {
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-2xs);
|
||||
}
|
||||
|
||||
.owners {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: 0;
|
||||
font-size: var(--font-xs);
|
||||
gap: var(--s-2);
|
||||
|
||||
& > li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
list-style: none;
|
||||
border-right: var(--border-style);
|
||||
padding-right: var(--s-2);
|
||||
}
|
||||
}
|
||||
|
||||
.ownerCount {
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-2xs);
|
||||
}
|
||||
323
app/features/trophies/routes/trophies.new.module.css
Normal file
323
app/features/trophies/routes/trophies.new.module.css
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
.terms {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-4);
|
||||
padding: var(--s-4);
|
||||
border: var(--border-style);
|
||||
border-radius: var(--radius-box);
|
||||
background-color: var(--color-bg-high);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.termsTitle {
|
||||
font-size: var(--font-md);
|
||||
color: var(--color-error-high);
|
||||
background-color: var(--color-error-low);
|
||||
padding: var(--s-2);
|
||||
border-radius: var(--radius-field);
|
||||
border: 1px solid var(--color-error);
|
||||
}
|
||||
|
||||
.termsGroupTitle {
|
||||
font-weight: var(--weight-semi);
|
||||
margin-bottom: var(--s-1);
|
||||
}
|
||||
|
||||
.termsList {
|
||||
list-style: disc;
|
||||
padding-inline-start: var(--s-6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.termsDisclaimer {
|
||||
font-weight: var(--weight-bold);
|
||||
margin-bottom: var(--s-1);
|
||||
}
|
||||
|
||||
.termsAgreeButton {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.modelTextarea {
|
||||
min-height: 160px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
.trophyPreview {
|
||||
border-radius: var(--radius-field);
|
||||
background-color: var(--color-bg-high);
|
||||
margin-top: var(--s-4);
|
||||
|
||||
&:has(div) {
|
||||
width: 100px;
|
||||
height: auto;
|
||||
aspect-ratio: 1 / 1;
|
||||
border: var(--border-width) solid var(--tier-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.previewThemes {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--s-4);
|
||||
margin-top: var(--s-4);
|
||||
}
|
||||
|
||||
.previewTheme {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
padding: var(--s-3);
|
||||
border: var(--border-style);
|
||||
border-radius: var(--radius-field);
|
||||
background-color: var(--color-bg);
|
||||
|
||||
& .trophyPreview {
|
||||
margin-top: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.previewThemeLabel {
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-semi);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.modelSpecs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-3);
|
||||
margin-top: var(--s-4);
|
||||
padding: var(--s-3);
|
||||
border: var(--border-style);
|
||||
border-radius: var(--radius-box);
|
||||
background-color: var(--color-bg-high);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.specList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
padding: 0;
|
||||
margin-top: var(--s-1);
|
||||
|
||||
& > li {
|
||||
list-style: none;
|
||||
}
|
||||
}
|
||||
|
||||
.specItem {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--s-1-5);
|
||||
}
|
||||
|
||||
.specIcon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.specIconPass {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.specIconFail {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.specIconWarn {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.specDetail {
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.pendingSpecs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-1) var(--s-2);
|
||||
font-size: var(--font-2xs);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.pendingSpecsWarn {
|
||||
color: var(--color-warning);
|
||||
font-weight: var(--weight-semi);
|
||||
}
|
||||
|
||||
.pendingSpecsError {
|
||||
color: var(--color-error);
|
||||
font-weight: var(--weight-semi);
|
||||
}
|
||||
|
||||
.descriptionTextarea {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.updateContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-4);
|
||||
}
|
||||
|
||||
.diffHeader {
|
||||
font-size: var(--font-2xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.diffGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--s-4) var(--s-2);
|
||||
padding: var(--s-2);
|
||||
border: var(--border-style);
|
||||
border-radius: var(--radius-field);
|
||||
background-color: var(--color-bg);
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
.diffField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.diffLabel {
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-2xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.diffValue {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.diffOld {
|
||||
color: var(--color-text-high);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.editingBadge {
|
||||
padding: 0 var(--s-1-5);
|
||||
border-radius: var(--radius-selector);
|
||||
background-color: var(--color-text-accent);
|
||||
color: var(--color-text-inverse);
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
.notManagerBadge {
|
||||
padding: 0 var(--s-1-5);
|
||||
border-radius: var(--radius-selector);
|
||||
background-color: var(--color-warning-low);
|
||||
color: var(--color-warning-high);
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
.pendingList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-4);
|
||||
}
|
||||
|
||||
.pendingItem {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr;
|
||||
gap: var(--s-4);
|
||||
padding: var(--s-2);
|
||||
border: var(--border-style);
|
||||
border-radius: var(--radius-box);
|
||||
background-color: var(--color-bg-high);
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.pendingMain {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pendingHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pendingName {
|
||||
font-weight: var(--weight-semi);
|
||||
}
|
||||
|
||||
.pendingMeta {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.pendingDescription {
|
||||
white-space: pre-wrap;
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.pendingActions {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.declined {
|
||||
background-color: var(--color-error-low);
|
||||
border: 1px solid var(--color-error);
|
||||
border-radius: var(--radius-field);
|
||||
padding: var(--s-2);
|
||||
font-size: var(--font-sm);
|
||||
|
||||
> p {
|
||||
color: var(--color-error-high);
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
}
|
||||
|
||||
.accepted {
|
||||
background-color: var(--color-success-low);
|
||||
border: 1px solid var(--color-success);
|
||||
border-radius: var(--radius-field);
|
||||
padding: var(--s-2);
|
||||
font-size: var(--font-sm);
|
||||
|
||||
> p {
|
||||
color: var(--color-success-high);
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
}
|
||||
|
||||
.trophyPreviewButton {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dialogForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-3);
|
||||
min-width: 280px;
|
||||
}
|
||||
966
app/features/trophies/routes/trophies.new.tsx
Normal file
966
app/features/trophies/routes/trophies.new.tsx
Normal file
|
|
@ -0,0 +1,966 @@
|
|||
import clsx from "clsx";
|
||||
import { Check, Clipboard, Dot, Trash2, TriangleAlert, X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import {
|
||||
Form,
|
||||
Link,
|
||||
type MetaFunction,
|
||||
useFetcher,
|
||||
useLoaderData,
|
||||
} from "react-router";
|
||||
import { Alert } from "~/components/Alert";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { OrganizationSearch } from "~/components/elements/OrganizationSearch";
|
||||
import { SendouSelect, SendouSelectItem } from "~/components/elements/Select";
|
||||
import {
|
||||
SendouTab,
|
||||
SendouTabList,
|
||||
SendouTabPanel,
|
||||
SendouTabs,
|
||||
} from "~/components/elements/Tabs";
|
||||
import { UserSearch } from "~/components/elements/UserSearch";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { Label } from "~/components/Label";
|
||||
import { Main } from "~/components/Main";
|
||||
import type { CustomFieldRenderProps } from "~/form/FormField";
|
||||
import { SendouForm } from "~/form/SendouForm";
|
||||
import { useDebounce } from "~/hooks/useDebounce";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import {
|
||||
navIconUrl,
|
||||
PICOCAD2_WEB_VIEWER_URL,
|
||||
SENDOU_INK_DISCORD_URL,
|
||||
TROPHIES_PAGE,
|
||||
tournamentOrganizationPage,
|
||||
userPage,
|
||||
} from "~/utils/urls";
|
||||
import { action } from "../actions/trophies.new.server";
|
||||
import { Trophy, TrophyContextProvider } from "../components/Trophy";
|
||||
import {
|
||||
analyzeTrophyModel,
|
||||
type TrophyModelAnalysis,
|
||||
} from "../core/model-analysis";
|
||||
import {
|
||||
loader,
|
||||
type NewTrophyLoaderData,
|
||||
} from "../loaders/trophies.new.server";
|
||||
import {
|
||||
TROPHY_APPROVALS_REQUIRED,
|
||||
TROPHY_DECLINE_REASON_MAX_LENGTH,
|
||||
TROPHY_MODEL_RECOMMENDED_MAX_DRAW_CALLS,
|
||||
TROPHY_MODEL_RECOMMENDED_MAX_EFFECTS,
|
||||
TROPHY_MODEL_RECOMMENDED_MAX_POLYS,
|
||||
TROPHY_PENDING_PER_USER_LIMIT,
|
||||
} from "../trophies-constants";
|
||||
import {
|
||||
createTrophyFormSchema,
|
||||
updateTrophyFormSchema,
|
||||
} from "../trophies-schemas";
|
||||
import {
|
||||
compressTrophyModel,
|
||||
decompressTrophyModel,
|
||||
useProgressiveRender,
|
||||
useTrophyTermsAgreement,
|
||||
} from "../trophies-utils";
|
||||
import styles from "./trophies.new.module.css";
|
||||
|
||||
export { action, loader };
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: "trophies",
|
||||
breadcrumb: () => ({
|
||||
imgPath: navIconUrl("trophies"),
|
||||
href: TROPHIES_PAGE,
|
||||
type: "IMAGE",
|
||||
}),
|
||||
};
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
return metaTags({
|
||||
title: "New trophy",
|
||||
ogTitle: "Submit a new trophy",
|
||||
location: args.location,
|
||||
description: "Submit a new trophy for review.",
|
||||
});
|
||||
};
|
||||
|
||||
export default function NewTrophyPage() {
|
||||
const { t } = useTranslation(["trophies"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<Main halfWidth>
|
||||
<SendouTabs>
|
||||
<SendouTabList>
|
||||
<SendouTab id="upload">{t("trophies:new.tabs.upload")}</SendouTab>
|
||||
<SendouTab
|
||||
id="update"
|
||||
isDisabled={data.editableTrophies.length === 0}
|
||||
>
|
||||
{t("trophies:new.tabs.update")}
|
||||
</SendouTab>
|
||||
<SendouTab id="pending" number={data.pendingTrophies.length}>
|
||||
{t("trophies:new.tabs.pending")}
|
||||
</SendouTab>
|
||||
<SendouTab id="reviewed">{t("trophies:new.tabs.reviewed")}</SendouTab>
|
||||
</SendouTabList>
|
||||
<SendouTabPanel id="upload">
|
||||
{data.ownUnreviewedCount >= TROPHY_PENDING_PER_USER_LIMIT ? (
|
||||
<Alert variation="WARNING">
|
||||
{t("trophies:new.form.limitReached", {
|
||||
limit: TROPHY_PENDING_PER_USER_LIMIT,
|
||||
})}
|
||||
</Alert>
|
||||
) : (
|
||||
<TrophyTermsGate>
|
||||
<NewTrophyForm key={data.ownUnreviewedCount} />
|
||||
</TrophyTermsGate>
|
||||
)}
|
||||
</SendouTabPanel>
|
||||
<SendouTabPanel id="update">
|
||||
{data.ownUnreviewedCount >= TROPHY_PENDING_PER_USER_LIMIT ? (
|
||||
<Alert variation="WARNING">
|
||||
{t("trophies:new.form.limitReached", {
|
||||
limit: TROPHY_PENDING_PER_USER_LIMIT,
|
||||
})}
|
||||
</Alert>
|
||||
) : (
|
||||
<UpdateTrophyTab key={data.ownUnreviewedCount} />
|
||||
)}
|
||||
</SendouTabPanel>
|
||||
<SendouTabPanel id="pending">
|
||||
<TrophyList items={data.pendingTrophies} listKind="pending" />
|
||||
</SendouTabPanel>
|
||||
<SendouTabPanel id="reviewed">
|
||||
<TrophyList items={data.reviewedTrophies} listKind="reviewed" />
|
||||
</SendouTabPanel>
|
||||
</SendouTabs>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function TrophyTermsGate({ children }: { children: React.ReactNode }) {
|
||||
const { t } = useTranslation(["trophies"]);
|
||||
const { hasAgreedToTerms, agreeToTerms } = useTrophyTermsAgreement();
|
||||
|
||||
if (hasAgreedToTerms) return children;
|
||||
|
||||
return (
|
||||
<div className={styles.terms}>
|
||||
<h2 className={styles.termsTitle}>{t("trophies:new.terms.title")}</h2>
|
||||
<p>{t("trophies:new.terms.intro")}</p>
|
||||
<div>
|
||||
<p className={styles.termsGroupTitle}>
|
||||
{t("trophies:new.terms.oneOff.title")}
|
||||
</p>
|
||||
<ul className={styles.termsList}>
|
||||
<li>{t("trophies:new.terms.trustedOrg")}</li>
|
||||
<li>{t("trophies:new.terms.oneOff.pastTournaments")}</li>
|
||||
<li>{t("trophies:new.terms.oneOff.projectedTeams")}</li>
|
||||
<li>{t("trophies:new.terms.oneOff.signedUpTeams")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className={styles.termsGroupTitle}>
|
||||
{t("trophies:new.terms.series.title")}
|
||||
</p>
|
||||
<ul className={styles.termsList}>
|
||||
<li>{t("trophies:new.terms.trustedOrg")}</li>
|
||||
<li>{t("trophies:new.terms.series.consistentTeams")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className={styles.termsDisclaimer}>
|
||||
{t("trophies:new.terms.disclaimer")}
|
||||
</p>
|
||||
<p>
|
||||
<Trans t={t} i18nKey="trophies:new.terms.preApproval">
|
||||
If you are unsure if your tournament is eligible, feel free to
|
||||
acquire a pre-approval by creating a pre-approval request in the
|
||||
<a
|
||||
href={SENDOU_INK_DISCORD_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Discord server
|
||||
</a>
|
||||
.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<SendouButton className={styles.termsAgreeButton} onPress={agreeToTerms}>
|
||||
{t("trophies:new.terms.agree")}
|
||||
</SendouButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewTrophyForm() {
|
||||
return (
|
||||
<SendouForm schema={createTrophyFormSchema}>
|
||||
{({ FormField }) => (
|
||||
<>
|
||||
<FormField name="name" />
|
||||
<FormField name="organizationId">
|
||||
{({ error, value, onChange }: CustomFieldRenderProps) => (
|
||||
<OrganizationField
|
||||
error={error}
|
||||
value={value as number | null}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField name="model">
|
||||
{({ name, error, value, onChange }: CustomFieldRenderProps) => (
|
||||
<ModelField
|
||||
name={name}
|
||||
error={error}
|
||||
value={value as string}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField name="description" />
|
||||
</>
|
||||
)}
|
||||
</SendouForm>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateTrophyTab() {
|
||||
const { t } = useTranslation(["trophies"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [selectedId, setSelectedId] = React.useState<number | null>(null);
|
||||
|
||||
const selectedTrophy = data.editableTrophies.find((t) => t.id === selectedId);
|
||||
|
||||
return (
|
||||
<div className={styles.updateContainer}>
|
||||
<SendouSelect
|
||||
label={t("trophies:new.update.selectLabel")}
|
||||
items={data.editableTrophies}
|
||||
search={{ placeholder: t("trophies:new.update.searchPlaceholder") }}
|
||||
selectedKey={selectedId}
|
||||
onSelectionChange={(key) => setSelectedId(key as number)}
|
||||
>
|
||||
{(trophy) => (
|
||||
<SendouSelectItem
|
||||
key={trophy.id}
|
||||
id={trophy.id}
|
||||
textValue={trophy.name}
|
||||
>
|
||||
{trophy.name}
|
||||
</SendouSelectItem>
|
||||
)}
|
||||
</SendouSelect>
|
||||
{selectedTrophy ? (
|
||||
<UpdateTrophyForm key={selectedTrophy.id} trophy={selectedTrophy} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateTrophyForm({
|
||||
trophy,
|
||||
}: {
|
||||
trophy: NewTrophyLoaderData["editableTrophies"][number];
|
||||
}) {
|
||||
const decompressedModel = decompressTrophyModel(trophy.model) ?? "";
|
||||
|
||||
return (
|
||||
<SendouForm
|
||||
schema={updateTrophyFormSchema}
|
||||
defaultValues={{
|
||||
targetTrophyId: trophy.id,
|
||||
name: trophy.name,
|
||||
model: decompressedModel,
|
||||
organizationId: trophy.organizationId,
|
||||
managerId: trophy.managerId,
|
||||
description: "",
|
||||
}}
|
||||
>
|
||||
{({ FormField }) => (
|
||||
<>
|
||||
<FormField name="name" />
|
||||
<FormField name="organizationId">
|
||||
{({ error, value, onChange }: CustomFieldRenderProps) => (
|
||||
<OrganizationField
|
||||
error={error}
|
||||
value={value as number | null}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField name="managerId">
|
||||
{({ error, value, onChange }: CustomFieldRenderProps) => (
|
||||
<ManagerField
|
||||
error={error}
|
||||
value={value as number | null}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField name="model">
|
||||
{({ name, error, value, onChange }: CustomFieldRenderProps) => (
|
||||
<ModelField
|
||||
name={name}
|
||||
error={error}
|
||||
value={value as string}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField name="description" />
|
||||
</>
|
||||
)}
|
||||
</SendouForm>
|
||||
);
|
||||
}
|
||||
|
||||
function ManagerField({
|
||||
error,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
error?: string;
|
||||
value: number | null;
|
||||
onChange: (value: number | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["forms"]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label required>{t("forms:labels.trophyManager")}</Label>
|
||||
<UserSearch
|
||||
initialUserId={value ?? undefined}
|
||||
onChange={(user) => onChange(user?.id ?? null)}
|
||||
/>
|
||||
{error ? <FormMessage type="error">{error}</FormMessage> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelField({
|
||||
name,
|
||||
error,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
name: string;
|
||||
error?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["forms", "trophies"]);
|
||||
const [preview, setPreview] = React.useState(() => buildModelPreview(value));
|
||||
|
||||
useDebounce(() => setPreview(buildModelPreview(value)), 500, [value]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor={name} required>
|
||||
{t("forms:labels.trophyModel")}
|
||||
</Label>
|
||||
<textarea
|
||||
id={name}
|
||||
className={styles.modelTextarea}
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<FormMessage type="info">
|
||||
{t("forms:bottomTexts.trophyModel")}
|
||||
<a
|
||||
href={PICOCAD2_WEB_VIEWER_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{" "}
|
||||
PicoCAD2 Web Viewer
|
||||
</a>
|
||||
</FormMessage>
|
||||
{error ? <FormMessage type="error">{error}</FormMessage> : null}
|
||||
{preview.compressedModel ? (
|
||||
<TrophyContextProvider>
|
||||
<div className={styles.previewThemes}>
|
||||
{(["light", "dark"] as const).map((theme) => (
|
||||
<div
|
||||
key={theme}
|
||||
className={clsx(styles.previewTheme, `${theme}-preview`)}
|
||||
>
|
||||
<span className={styles.previewThemeLabel}>
|
||||
{t(`trophies:new.form.preview.${theme}`)}
|
||||
</span>
|
||||
<Trophy
|
||||
model={preview.compressedModel}
|
||||
className={styles.trophyPreview}
|
||||
preview
|
||||
tier={1}
|
||||
/>
|
||||
<Trophy
|
||||
model={preview.compressedModel}
|
||||
className={styles.trophyPreview}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TrophyContextProvider>
|
||||
) : null}
|
||||
<ModelSpecs analysis={preview.analysis} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildModelPreview(model: string) {
|
||||
if (!model) return { compressedModel: "", analysis: null };
|
||||
|
||||
return {
|
||||
compressedModel: compressTrophyModel(model),
|
||||
analysis: analyzeTrophyModel(model),
|
||||
};
|
||||
}
|
||||
|
||||
function ModelSpecs({ analysis }: { analysis: TrophyModelAnalysis | null }) {
|
||||
const { t } = useTranslation(["trophies"]);
|
||||
|
||||
const enforcedStatus = (passes: boolean) =>
|
||||
analysis ? (passes ? "pass" : "fail") : null;
|
||||
const recommendedStatus = (withinLimit: boolean) =>
|
||||
analysis ? (withinLimit ? "pass" : "warn") : null;
|
||||
|
||||
return (
|
||||
<div className={styles.modelSpecs}>
|
||||
<div>
|
||||
<div className={styles.termsGroupTitle}>
|
||||
{t("trophies:new.specs.required")}
|
||||
</div>
|
||||
<ul className={styles.specList}>
|
||||
<SpecItem status={enforcedStatus(!!analysis?.cameraTargetCentered)}>
|
||||
{t("trophies:new.specs.cameraTarget")}
|
||||
</SpecItem>
|
||||
<SpecItem status={enforcedStatus(!!analysis?.backgroundIsAlpha)}>
|
||||
{t("trophies:new.specs.background")}
|
||||
</SpecItem>
|
||||
<SpecItem>{t("trophies:new.specs.centered")}</SpecItem>
|
||||
<SpecItem>{t("trophies:new.specs.zoom")}</SpecItem>
|
||||
<SpecItem>{t("trophies:new.specs.angles")}</SpecItem>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<div className={styles.termsGroupTitle}>
|
||||
{t("trophies:new.specs.recommended")}
|
||||
</div>
|
||||
<ul className={styles.specList}>
|
||||
<SpecItem
|
||||
status={recommendedStatus(
|
||||
!!analysis &&
|
||||
analysis.drawCalls <= TROPHY_MODEL_RECOMMENDED_MAX_DRAW_CALLS,
|
||||
)}
|
||||
detail={
|
||||
analysis
|
||||
? t("trophies:new.specs.currentValue", {
|
||||
value: analysis.drawCalls,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t("trophies:new.specs.drawCalls", {
|
||||
value: TROPHY_MODEL_RECOMMENDED_MAX_DRAW_CALLS,
|
||||
})}
|
||||
</SpecItem>
|
||||
<SpecItem
|
||||
status={recommendedStatus(
|
||||
!!analysis &&
|
||||
analysis.polyCount <= TROPHY_MODEL_RECOMMENDED_MAX_POLYS,
|
||||
)}
|
||||
detail={
|
||||
analysis
|
||||
? t("trophies:new.specs.currentValue", {
|
||||
value: analysis.polyCount,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t("trophies:new.specs.polys", {
|
||||
value: TROPHY_MODEL_RECOMMENDED_MAX_POLYS,
|
||||
})}
|
||||
</SpecItem>
|
||||
<SpecItem
|
||||
status={recommendedStatus(
|
||||
!!analysis &&
|
||||
analysis.effectsCount <= TROPHY_MODEL_RECOMMENDED_MAX_EFFECTS,
|
||||
)}
|
||||
detail={
|
||||
analysis
|
||||
? t("trophies:new.specs.currentValue", {
|
||||
value: analysis.effectsCount,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t("trophies:new.specs.effects", {
|
||||
value: TROPHY_MODEL_RECOMMENDED_MAX_EFFECTS,
|
||||
})}
|
||||
</SpecItem>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpecItem({
|
||||
children,
|
||||
status,
|
||||
detail,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
status?: "pass" | "fail" | "warn" | null;
|
||||
detail?: string;
|
||||
}) {
|
||||
return (
|
||||
<li className={styles.specItem}>
|
||||
{status === "pass" ? (
|
||||
<Check className={clsx(styles.specIcon, styles.specIconPass)} />
|
||||
) : status === "fail" ? (
|
||||
<X className={clsx(styles.specIcon, styles.specIconFail)} />
|
||||
) : status === "warn" ? (
|
||||
<TriangleAlert className={clsx(styles.specIcon, styles.specIconWarn)} />
|
||||
) : (
|
||||
<Dot className={styles.specIcon} />
|
||||
)}
|
||||
<span>
|
||||
{children}
|
||||
{detail ? <span className={styles.specDetail}> {detail}</span> : null}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function OrganizationField({
|
||||
error,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
error?: string;
|
||||
value: number | null;
|
||||
onChange: (value: number | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["forms"]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label required>{t("forms:labels.trophyOrganization")}</Label>
|
||||
<OrganizationSearch
|
||||
initialOrganizationId={value ?? undefined}
|
||||
onChange={(org) => onChange(org?.id ?? null)}
|
||||
/>
|
||||
{error ? <FormMessage type="error">{error}</FormMessage> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrophyList({
|
||||
items,
|
||||
listKind,
|
||||
}: {
|
||||
items: NewTrophyLoaderData["pendingTrophies"];
|
||||
listKind: "pending" | "reviewed";
|
||||
}) {
|
||||
const { t } = useTranslation(["trophies"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const visibleCount = useProgressiveRender(items.length, listKind);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<Alert>
|
||||
{listKind === "pending"
|
||||
? t("trophies:new.pending.empty")
|
||||
: t("trophies:new.reviewed.empty")}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TrophyContextProvider>
|
||||
<div className={styles.pendingList}>
|
||||
{items.slice(0, visibleCount).map((item) => (
|
||||
<TrophyListRow
|
||||
key={item.id}
|
||||
pending={item}
|
||||
currentUserId={data.currentUserId}
|
||||
canReview={data.canReview}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TrophyContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TrophyListRow({
|
||||
pending,
|
||||
currentUserId,
|
||||
canReview,
|
||||
}: {
|
||||
pending: NewTrophyLoaderData["pendingTrophies"][number];
|
||||
currentUserId: number;
|
||||
canReview: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["trophies", "common"]);
|
||||
const fetcher = useFetcher();
|
||||
|
||||
const isOwner = pending.submitterUserId === currentUserId;
|
||||
const isDeclined = pending.declinedAt !== null;
|
||||
const isAccepted = pending.approvals.length >= TROPHY_APPROVALS_REQUIRED;
|
||||
const isReviewed = isDeclined || isAccepted;
|
||||
const alreadyApproved = pending.approvals.some(
|
||||
(a) => a.userId === currentUserId,
|
||||
);
|
||||
|
||||
const handleDelete = () => {
|
||||
const formData = new FormData();
|
||||
formData.append("_action", "DELETE");
|
||||
formData.append("pendingTrophyId", String(pending.id));
|
||||
fetcher.submit(formData, { method: "post" });
|
||||
};
|
||||
|
||||
const handleApprove = () => {
|
||||
const formData = new FormData();
|
||||
formData.append("_action", "APPROVE");
|
||||
formData.append("pendingTrophyId", String(pending.id));
|
||||
fetcher.submit(formData, { method: "post" });
|
||||
};
|
||||
|
||||
const [previewOpen, setPreviewOpen] = React.useState(false);
|
||||
|
||||
const [analysis] = React.useState(() =>
|
||||
analyzeTrophyModel(decompressTrophyModel(pending.model) ?? ""),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.pendingItem}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.trophyPreviewButton}
|
||||
onClick={() => setPreviewOpen(true)}
|
||||
>
|
||||
<Trophy model={pending.model} preview />
|
||||
</button>
|
||||
<SendouDialog
|
||||
heading={pending.name}
|
||||
isOpen={previewOpen}
|
||||
onClose={() => setPreviewOpen(false)}
|
||||
showCloseButton
|
||||
>
|
||||
<Trophy model={pending.model} className={styles.trophyPreview} />
|
||||
</SendouDialog>
|
||||
<div className={styles.pendingMain}>
|
||||
<div className={styles.pendingHeader}>
|
||||
{pending.target ? (
|
||||
<span className={styles.editingBadge}>
|
||||
{t("trophies:new.update.editing")}
|
||||
</span>
|
||||
) : null}
|
||||
{pending.target &&
|
||||
pending.submitterUserId !== pending.target.managerId ? (
|
||||
<span className={styles.notManagerBadge}>
|
||||
{t("trophies:new.pending.notFromManager")} (
|
||||
{pending.submitterUsername})
|
||||
</span>
|
||||
) : null}
|
||||
<span className={styles.pendingName}>{pending.name}</span>
|
||||
<span className={styles.pendingMeta}>
|
||||
{pending.manager?.discordId ? (
|
||||
<Link to={userPage({ discordId: pending.manager.discordId })}>
|
||||
{pending.manager.username}
|
||||
</Link>
|
||||
) : pending.submitterDiscordId ? (
|
||||
<Link to={userPage({ discordId: pending.submitterDiscordId })}>
|
||||
{pending.submitterUsername}
|
||||
</Link>
|
||||
) : (
|
||||
pending.submitterUsername
|
||||
)}
|
||||
{pending.organizationName ? (
|
||||
<>
|
||||
{" • "}
|
||||
{pending.organizationSlug ? (
|
||||
<Link
|
||||
to={tournamentOrganizationPage({
|
||||
organizationSlug: pending.organizationSlug,
|
||||
})}
|
||||
>
|
||||
{pending.organizationName}
|
||||
</Link>
|
||||
) : (
|
||||
pending.organizationName
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
{analysis ? (
|
||||
<div className={styles.pendingSpecs}>
|
||||
<span
|
||||
className={clsx({
|
||||
[styles.pendingSpecsWarn]:
|
||||
analysis.drawCalls > TROPHY_MODEL_RECOMMENDED_MAX_DRAW_CALLS,
|
||||
})}
|
||||
>
|
||||
{t("trophies:new.specs.stats.drawCalls", {
|
||||
value: analysis.drawCalls,
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
className={clsx({
|
||||
[styles.pendingSpecsWarn]:
|
||||
analysis.polyCount > TROPHY_MODEL_RECOMMENDED_MAX_POLYS,
|
||||
})}
|
||||
>
|
||||
{t("trophies:new.specs.stats.polys", {
|
||||
value: analysis.polyCount,
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
className={clsx({
|
||||
[styles.pendingSpecsWarn]:
|
||||
analysis.effectsCount > TROPHY_MODEL_RECOMMENDED_MAX_EFFECTS,
|
||||
})}
|
||||
>
|
||||
{t("trophies:new.specs.stats.effects", {
|
||||
value: analysis.effectsCount,
|
||||
})}
|
||||
</span>
|
||||
{!analysis.cameraTargetCentered ? (
|
||||
<span className={styles.pendingSpecsError}>
|
||||
{t("trophies:new.specs.stats.cameraTargetOff")}
|
||||
</span>
|
||||
) : null}
|
||||
{!analysis.backgroundIsAlpha ? (
|
||||
<span className={styles.pendingSpecsError}>
|
||||
{t("trophies:new.specs.stats.backgroundNotAlpha")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{pending.target ? (
|
||||
<PendingTrophyDiff pending={pending} target={pending.target} />
|
||||
) : null}
|
||||
{pending.description ? (
|
||||
<div className={styles.pendingDescription}>{pending.description}</div>
|
||||
) : null}
|
||||
{pending.approvals.length > 0 && !isDeclined ? (
|
||||
<div className={styles.accepted}>
|
||||
<p>
|
||||
{t("trophies:new.pending.approvalProgress", {
|
||||
current: pending.approvals.length,
|
||||
required: TROPHY_APPROVALS_REQUIRED,
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{pending.approvals.some((a) => a.userId === currentUserId)
|
||||
? `(${pending.approvals.map((a) => a.username).join(", ")})`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{isDeclined ? (
|
||||
<div className={styles.declined}>
|
||||
<p>
|
||||
{pending.declinedByUsername
|
||||
? t("trophies:new.pending.declinedBy", {
|
||||
name: pending.declinedByUsername,
|
||||
})
|
||||
: t("trophies:new.pending.declined")}
|
||||
</p>
|
||||
<div>{pending.declineReason}</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.pendingActions}>
|
||||
{canReview && !isReviewed ? (
|
||||
<>
|
||||
<SendouButton
|
||||
size="small"
|
||||
onPress={handleApprove}
|
||||
isDisabled={fetcher.state !== "idle" || alreadyApproved}
|
||||
>
|
||||
{alreadyApproved
|
||||
? t("trophies:new.pending.approved")
|
||||
: t("trophies:new.pending.approve")}
|
||||
</SendouButton>
|
||||
<DeclineButton pendingTrophyId={pending.id} />
|
||||
<SendouButton
|
||||
variant="outlined"
|
||||
size="small"
|
||||
shape="square"
|
||||
icon={<Clipboard size={16} />}
|
||||
onPress={() =>
|
||||
navigator.clipboard.writeText(
|
||||
decompressTrophyModel(pending.model ?? "{}") ?? "",
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{isOwner || canReview ? (
|
||||
<SendouButton
|
||||
variant="minimal-destructive"
|
||||
size="small"
|
||||
shape="square"
|
||||
onPress={handleDelete}
|
||||
isDisabled={fetcher.state !== "idle"}
|
||||
icon={<Trash2 size={16} />}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeclineButton({ pendingTrophyId }: { pendingTrophyId: number }) {
|
||||
const { t } = useTranslation(["trophies"]);
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [reason, setReason] = React.useState("");
|
||||
const fetcher = useFetcher();
|
||||
const id = React.useId();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (fetcher.state === "idle" && fetcher.data === null && isOpen) {
|
||||
setIsOpen(false);
|
||||
setReason("");
|
||||
}
|
||||
}, [fetcher.state, fetcher.data, isOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SendouButton
|
||||
variant="outlined-destructive"
|
||||
size="small"
|
||||
onPress={() => setIsOpen(true)}
|
||||
>
|
||||
{t("trophies:new.pending.decline")}
|
||||
</SendouButton>
|
||||
{isOpen ? (
|
||||
<SendouDialog
|
||||
heading={t("trophies:new.pending.declineHeading")}
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
showCloseButton
|
||||
>
|
||||
<Form
|
||||
method="post"
|
||||
className={styles.dialogForm}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData();
|
||||
formData.append("_action", "DECLINE");
|
||||
formData.append("pendingTrophyId", String(pendingTrophyId));
|
||||
formData.append("reason", reason);
|
||||
fetcher.submit(formData, { method: "post" });
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Label
|
||||
htmlFor={id}
|
||||
required
|
||||
valueLimits={{
|
||||
current: reason.length,
|
||||
max: TROPHY_DECLINE_REASON_MAX_LENGTH,
|
||||
}}
|
||||
>
|
||||
{t("trophies:new.pending.declineReason")}
|
||||
</Label>
|
||||
<textarea
|
||||
id={id}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
maxLength={TROPHY_DECLINE_REASON_MAX_LENGTH}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<SendouButton
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
isDisabled={!reason.trim() || fetcher.state !== "idle"}
|
||||
>
|
||||
{t("trophies:new.pending.decline")}
|
||||
</SendouButton>
|
||||
</Form>
|
||||
</SendouDialog>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PendingTrophyDiff({
|
||||
pending,
|
||||
target,
|
||||
}: {
|
||||
pending: NewTrophyLoaderData["pendingTrophies"][number];
|
||||
target: NonNullable<NewTrophyLoaderData["pendingTrophies"][number]["target"]>;
|
||||
}) {
|
||||
const { t } = useTranslation(["trophies", "forms"]);
|
||||
|
||||
const newManagerId = pending.managerId ?? pending.submitterUserId;
|
||||
const newManagerName =
|
||||
pending.manager?.username ?? pending.submitterUsername ?? "?";
|
||||
|
||||
const fields: Array<{
|
||||
label: string;
|
||||
oldValue: React.ReactNode;
|
||||
newValue: React.ReactNode;
|
||||
changed: boolean;
|
||||
}> = [
|
||||
{
|
||||
label: t("forms:labels.trophyName"),
|
||||
oldValue: target.name,
|
||||
newValue: pending.name,
|
||||
changed: target.name !== pending.name,
|
||||
},
|
||||
{
|
||||
label: t("forms:labels.trophyOrganization"),
|
||||
oldValue: target.organizationName ?? "—",
|
||||
newValue: pending.organizationName ?? "—",
|
||||
changed: target.organizationId !== pending.organizationId,
|
||||
},
|
||||
{
|
||||
label: t("forms:labels.trophyManager"),
|
||||
oldValue: target.managerUsername ?? "—",
|
||||
newValue: newManagerName,
|
||||
changed: target.managerId !== newManagerId,
|
||||
},
|
||||
{
|
||||
label: t("forms:labels.trophyModel"),
|
||||
oldValue: "-----",
|
||||
newValue: "-----",
|
||||
changed: target.model !== pending.model,
|
||||
},
|
||||
];
|
||||
|
||||
const changedFields = fields.filter((field) => field.changed);
|
||||
if (changedFields.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.diffGrid}>
|
||||
<div className={styles.diffHeader}>{t("trophies:new.update.before")}</div>
|
||||
<div className={styles.diffHeader}>{t("trophies:new.update.after")}</div>
|
||||
{changedFields.map((field) => (
|
||||
<React.Fragment key={field.label}>
|
||||
<div className={styles.diffField}>
|
||||
<span className={styles.diffLabel}>{field.label}</span>
|
||||
<span className={clsx(styles.diffValue, styles.diffOld)}>
|
||||
{field.oldValue}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.diffField}>
|
||||
<span className={styles.diffLabel}>{field.label}</span>
|
||||
<span className={styles.diffValue}>{field.newValue}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
app/features/trophies/routes/trophies.tsx
Normal file
112
app/features/trophies/routes/trophies.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { CalendarClock, Search } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Link,
|
||||
type MetaFunction,
|
||||
NavLink,
|
||||
Outlet,
|
||||
useLoaderData,
|
||||
} from "react-router";
|
||||
import { Input } from "~/components/Input";
|
||||
import { Main } from "~/components/Main";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { BADGES_PAGE, navIconUrl, TROPHIES_PAGE } from "~/utils/urls";
|
||||
import { metaTags } from "../../../utils/remix";
|
||||
import {
|
||||
Trophy,
|
||||
TrophyContextProvider,
|
||||
TrophyGrid,
|
||||
TrophyPlaceholder,
|
||||
} from "../components/Trophy";
|
||||
import { loader } from "../loaders/trophies.server";
|
||||
import {
|
||||
hasUpcomingTournamentSoon,
|
||||
useProgressiveRender,
|
||||
} from "../trophies-utils";
|
||||
import styles from "./trophies.module.css";
|
||||
|
||||
export { loader };
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: "trophies",
|
||||
breadcrumb: () => ({
|
||||
imgPath: navIconUrl("trophies"),
|
||||
href: TROPHIES_PAGE,
|
||||
type: "IMAGE",
|
||||
}),
|
||||
};
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
return metaTags({
|
||||
title: "Trophies",
|
||||
ogTitle: "Splatoon trophies for tournaments",
|
||||
location: args.location,
|
||||
description:
|
||||
"A full list of all trophies that can be won in Splatoon tournaments.",
|
||||
});
|
||||
};
|
||||
|
||||
export default function TrophiesPage() {
|
||||
const { t } = useTranslation(["trophies"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const inputValueNormalized = inputValue.toLowerCase();
|
||||
const filteredTrophies = data.trophies.filter((trophy) =>
|
||||
trophy.name.toLowerCase().includes(inputValueNormalized),
|
||||
);
|
||||
const visibleCount = useProgressiveRender(
|
||||
filteredTrophies.length,
|
||||
inputValue,
|
||||
);
|
||||
|
||||
return (
|
||||
<Main>
|
||||
<div className={styles.mainContent}>
|
||||
<Outlet />
|
||||
<div className={styles.trophiesListContainer}>
|
||||
<Input
|
||||
icon={<Search />}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
/>
|
||||
<TrophyGrid>
|
||||
<TrophyContextProvider>
|
||||
{filteredTrophies.map((trophy, i) =>
|
||||
i < visibleCount ? (
|
||||
<NavLink to={String(trophy.id)} key={trophy.id}>
|
||||
<Trophy
|
||||
tile
|
||||
model={trophy.model}
|
||||
tier={trophy.tier}
|
||||
tentativeTier={trophy.tentativeTier}
|
||||
preview
|
||||
pill={
|
||||
hasUpcomingTournamentSoon(
|
||||
trophy.upcomingTournamentAt,
|
||||
) ? (
|
||||
<CalendarClock
|
||||
size={16}
|
||||
role="img"
|
||||
aria-label={t("trophies:details.upcoming")}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</NavLink>
|
||||
) : (
|
||||
<TrophyPlaceholder key={trophy.id} />
|
||||
),
|
||||
)}
|
||||
</TrophyContextProvider>
|
||||
</TrophyGrid>
|
||||
</div>
|
||||
<p className={styles.badgesLink}>
|
||||
{t("trophies:lookingForBadges")}{" "}
|
||||
<Link to={BADGES_PAGE}>{t("trophies:viewBadges")}</Link>
|
||||
</p>
|
||||
</div>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
25
app/features/trophies/trophies-constants.ts
Normal file
25
app/features/trophies/trophies-constants.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
export const TROPHY_NAME_MIN_LENGTH = 1;
|
||||
export const TROPHY_NAME_MAX_LENGTH = 32;
|
||||
|
||||
export const TROPHY_MODEL_MAX_LENGTH = 1024 * 1024;
|
||||
export const TROPHY_MODEL_RECOMMENDED_MAX_DRAW_CALLS = 15;
|
||||
export const TROPHY_MODEL_RECOMMENDED_MAX_POLYS = 3000;
|
||||
export const TROPHY_MODEL_RECOMMENDED_MAX_EFFECTS = 5;
|
||||
|
||||
export const TROPHY_APPROVALS_REQUIRED = 2;
|
||||
|
||||
export const TROPHY_DESCRIPTION_MAX_LENGTH = 3000;
|
||||
|
||||
export const TROPHY_DECLINE_REASON_MIN_LENGTH = 1;
|
||||
export const TROPHY_DECLINE_REASON_MAX_LENGTH = 3000;
|
||||
|
||||
export const TROPHY_PENDING_PER_USER_LIMIT = 5;
|
||||
|
||||
export const SMALL_TROPHIES_PER_DISPLAY_PAGE = 6;
|
||||
|
||||
export const TROPHY_UPCOMING_HIGHLIGHT_WEEKS = 4;
|
||||
|
||||
export const TROPHIES_RELEASED = false;
|
||||
|
||||
export const SUPPORTER_TROPHY_CODE = "supporter";
|
||||
export const XP_TROPHY_CODE_PREFIX = "xp-";
|
||||
106
app/features/trophies/trophies-schemas.ts
Normal file
106
app/features/trophies/trophies-schemas.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { z } from "zod";
|
||||
import {
|
||||
customField,
|
||||
stringConstant,
|
||||
textAreaOptional,
|
||||
textField,
|
||||
} from "~/form/fields";
|
||||
import { _action, id } from "~/utils/zod";
|
||||
import { analyzeTrophyModel } from "./core/model-analysis";
|
||||
import {
|
||||
TROPHY_DECLINE_REASON_MAX_LENGTH,
|
||||
TROPHY_DECLINE_REASON_MIN_LENGTH,
|
||||
TROPHY_DESCRIPTION_MAX_LENGTH,
|
||||
TROPHY_MODEL_MAX_LENGTH,
|
||||
TROPHY_NAME_MAX_LENGTH,
|
||||
TROPHY_NAME_MIN_LENGTH,
|
||||
} from "./trophies-constants";
|
||||
|
||||
const trophyModelField = () =>
|
||||
customField(
|
||||
{ initialValue: "" },
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(TROPHY_MODEL_MAX_LENGTH)
|
||||
.superRefine((model, ctx) => {
|
||||
const analysis = analyzeTrophyModel(model);
|
||||
|
||||
if (!analysis) {
|
||||
ctx.addIssue({ code: "custom", message: "Invalid model state" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!analysis.cameraTargetCentered) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Camera target X and Z must be 0",
|
||||
});
|
||||
}
|
||||
|
||||
if (!analysis.backgroundIsAlpha) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Background color must be the alpha color",
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const createTrophyFormSchema = z.object({
|
||||
_action: stringConstant("CREATE"),
|
||||
name: textField({
|
||||
label: "labels.trophyName",
|
||||
minLength: TROPHY_NAME_MIN_LENGTH,
|
||||
maxLength: TROPHY_NAME_MAX_LENGTH,
|
||||
}),
|
||||
model: trophyModelField(),
|
||||
organizationId: customField({ initialValue: null }, id),
|
||||
description: textAreaOptional({
|
||||
label: "labels.trophyInformation",
|
||||
maxLength: TROPHY_DESCRIPTION_MAX_LENGTH,
|
||||
}),
|
||||
});
|
||||
|
||||
export const updateTrophyFormSchema = z.object({
|
||||
_action: stringConstant("UPDATE"),
|
||||
targetTrophyId: customField({ initialValue: null }, id),
|
||||
name: textField({
|
||||
label: "labels.trophyName",
|
||||
minLength: TROPHY_NAME_MIN_LENGTH,
|
||||
maxLength: TROPHY_NAME_MAX_LENGTH,
|
||||
}),
|
||||
model: trophyModelField(),
|
||||
organizationId: customField({ initialValue: null }, id),
|
||||
managerId: customField({ initialValue: null }, id),
|
||||
description: textAreaOptional({
|
||||
label: "labels.trophyInformation",
|
||||
maxLength: TROPHY_DESCRIPTION_MAX_LENGTH,
|
||||
}),
|
||||
});
|
||||
|
||||
export const trophyFormSchema = z.discriminatedUnion("_action", [
|
||||
createTrophyFormSchema,
|
||||
updateTrophyFormSchema,
|
||||
]);
|
||||
|
||||
export const pendingTrophyActionSchema = z.union([
|
||||
z.object({
|
||||
_action: _action("DELETE"),
|
||||
pendingTrophyId: id,
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("DECLINE"),
|
||||
pendingTrophyId: id,
|
||||
reason: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(TROPHY_DECLINE_REASON_MIN_LENGTH)
|
||||
.max(TROPHY_DECLINE_REASON_MAX_LENGTH),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("APPROVE"),
|
||||
pendingTrophyId: id,
|
||||
}),
|
||||
]);
|
||||
123
app/features/trophies/trophies-utils.test.ts
Normal file
123
app/features/trophies/trophies-utils.test.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { addWeeks, subDays } from "date-fns";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { decompressFromBase64 } from "~/utils/compression";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import {
|
||||
canAccessTrophies,
|
||||
decompressTrophyModel,
|
||||
hasUpcomingTournamentSoon,
|
||||
} from "./trophies-utils";
|
||||
|
||||
vi.mock("~/utils/compression", () => ({
|
||||
compressToBase64: vi.fn((value: string) => value),
|
||||
decompressFromBase64: vi.fn((_compressed: string): string | null => null),
|
||||
}));
|
||||
|
||||
const decompressMock = vi.mocked(decompressFromBase64);
|
||||
|
||||
const ENTRY_CHARS = 3_500_000;
|
||||
|
||||
describe("decompressTrophyModel", () => {
|
||||
beforeEach(() => {
|
||||
decompressMock.mockReset();
|
||||
});
|
||||
|
||||
test("decompresses each distinct model once", () => {
|
||||
flushCache("once");
|
||||
decompressMock.mockImplementation((key) => `decompressed-${key}`);
|
||||
|
||||
expect(decompressTrophyModel("once-model")).toBe("decompressed-once-model");
|
||||
expect(decompressTrophyModel("once-model")).toBe("decompressed-once-model");
|
||||
expect(callsFor("once-model")).toBe(1);
|
||||
});
|
||||
|
||||
test("caches null results of corrupt models", () => {
|
||||
flushCache("corrupt");
|
||||
decompressMock.mockImplementation(() => null);
|
||||
|
||||
expect(decompressTrophyModel("corrupt-model")).toBe(null);
|
||||
expect(decompressTrophyModel("corrupt-model")).toBe(null);
|
||||
expect(callsFor("corrupt-model")).toBe(1);
|
||||
});
|
||||
|
||||
test("evicts the least recently used entry when over the character budget", () => {
|
||||
flushCache("lru");
|
||||
decompressMock.mockImplementation(() => "x".repeat(ENTRY_CHARS));
|
||||
|
||||
decompressTrophyModel("lru-a");
|
||||
decompressTrophyModel("lru-b");
|
||||
decompressTrophyModel("lru-c");
|
||||
decompressTrophyModel("lru-d");
|
||||
|
||||
decompressTrophyModel("lru-a");
|
||||
expect(callsFor("lru-a")).toBe(1);
|
||||
|
||||
decompressTrophyModel("lru-e");
|
||||
|
||||
expect(decompressTrophyModel("lru-a")?.length).toBe(ENTRY_CHARS);
|
||||
expect(callsFor("lru-a")).toBe(1);
|
||||
|
||||
decompressTrophyModel("lru-b");
|
||||
expect(callsFor("lru-b")).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canAccessTrophies (before release)", () => {
|
||||
test("true for admin", () => {
|
||||
expect(canAccessTrophies({ roles: ["ADMIN"] })).toBe(true);
|
||||
});
|
||||
|
||||
test("true for QA", () => {
|
||||
expect(canAccessTrophies({ roles: ["QA"] })).toBe(true);
|
||||
});
|
||||
|
||||
test("false for staff", () => {
|
||||
expect(canAccessTrophies({ roles: ["STAFF"] })).toBe(false);
|
||||
});
|
||||
|
||||
test("false for regular and logged out users", () => {
|
||||
expect(canAccessTrophies({ roles: [] })).toBe(false);
|
||||
expect(canAccessTrophies(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasUpcomingTournamentSoon", () => {
|
||||
test("false for a trophy without an upcoming tournament", () => {
|
||||
expect(hasUpcomingTournamentSoon(null)).toBe(false);
|
||||
});
|
||||
|
||||
test("true for a start time within the window", () => {
|
||||
expect(
|
||||
hasUpcomingTournamentSoon(
|
||||
dateToDatabaseTimestamp(addWeeks(new Date(), 2)),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("false for a start time in the past", () => {
|
||||
expect(
|
||||
hasUpcomingTournamentSoon(
|
||||
dateToDatabaseTimestamp(subDays(new Date(), 1)),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("false for a start time beyond the window", () => {
|
||||
expect(
|
||||
hasUpcomingTournamentSoon(
|
||||
dateToDatabaseTimestamp(addWeeks(new Date(), 5)),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function callsFor(key: string) {
|
||||
return decompressMock.mock.calls.filter(([arg]) => arg === key).length;
|
||||
}
|
||||
|
||||
function flushCache(prefix: string) {
|
||||
decompressMock.mockImplementation(() => "x".repeat(17 * 1024 * 1024));
|
||||
decompressTrophyModel(`${prefix}-flush-filler`);
|
||||
decompressMock.mockImplementation(() => "");
|
||||
decompressTrophyModel(`${prefix}-flush-remainder`);
|
||||
}
|
||||
166
app/features/trophies/trophies-utils.ts
Normal file
166
app/features/trophies/trophies-utils.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import { addWeeks } from "date-fns";
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import type { Role } from "~/modules/permissions/types";
|
||||
import { compressToBase64, decompressFromBase64 } from "~/utils/compression";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import {
|
||||
SUPPORTER_TROPHY_CODE,
|
||||
TROPHIES_RELEASED,
|
||||
TROPHY_UPCOMING_HIGHLIGHT_WEEKS,
|
||||
XP_TROPHY_CODE_PREFIX,
|
||||
} from "./trophies-constants";
|
||||
|
||||
const TERMS_AGREED_SESSION_STORAGE_KEY = "trophyTermsAgreed";
|
||||
|
||||
const DECOMPRESSED_MODEL_CACHE_MAX_CHARS = 16 * 1024 * 1024;
|
||||
|
||||
type SpecialTrophyKind = { type: "supporter" } | { type: "xp"; value: number };
|
||||
|
||||
export function parseSpecialTrophyCode(
|
||||
code: string | null | undefined,
|
||||
): SpecialTrophyKind | null {
|
||||
if (!code) return null;
|
||||
if (code === SUPPORTER_TROPHY_CODE) return { type: "supporter" };
|
||||
|
||||
if (code.startsWith(XP_TROPHY_CODE_PREFIX)) {
|
||||
const value = Number(code.slice(XP_TROPHY_CODE_PREFIX.length));
|
||||
if (Number.isFinite(value)) return { type: "xp", value };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Feature flag
|
||||
export function canAccessTrophies(user?: { roles: Array<Role> } | null) {
|
||||
if (TROPHIES_RELEASED) return true;
|
||||
if (!user) return false;
|
||||
|
||||
return user.roles.includes("ADMIN") || user.roles.includes("QA");
|
||||
}
|
||||
|
||||
export function canReviewTrophies(user?: { roles: Array<Role> } | null) {
|
||||
if (!user) return false;
|
||||
|
||||
return user.roles.includes("STAFF") || user.roles.includes("QA");
|
||||
}
|
||||
|
||||
export function canEditAnyTrophy(user?: { roles: Array<Role> } | null) {
|
||||
if (!user) return false;
|
||||
|
||||
return user.roles.includes("ADMIN");
|
||||
}
|
||||
|
||||
export function canEditTrophy(
|
||||
user: { id: number; roles: Array<Role> } | null | undefined,
|
||||
trophy: { managerId: number | null },
|
||||
) {
|
||||
if (!user) return false;
|
||||
if (canEditAnyTrophy(user)) return true;
|
||||
return trophy.managerId === user.id;
|
||||
}
|
||||
|
||||
export function hasUpcomingTournamentSoon(
|
||||
upcomingTournamentAt: number | null | undefined,
|
||||
) {
|
||||
if (!upcomingTournamentAt) return false;
|
||||
|
||||
const startTime = databaseTimestampToDate(upcomingTournamentAt);
|
||||
const now = new Date();
|
||||
|
||||
return (
|
||||
startTime > now &&
|
||||
startTime <= addWeeks(now, TROPHY_UPCOMING_HIGHLIGHT_WEEKS)
|
||||
);
|
||||
}
|
||||
|
||||
export function compressTrophyModel(model: string) {
|
||||
return compressToBase64(model);
|
||||
}
|
||||
|
||||
const decompressedModelCache = new Map<string, string | null>();
|
||||
let decompressedModelCacheChars = 0;
|
||||
|
||||
export function decompressTrophyModel(modelBase64: string) {
|
||||
const cached = decompressedModelCache.get(modelBase64);
|
||||
if (cached !== undefined) {
|
||||
decompressedModelCache.delete(modelBase64);
|
||||
decompressedModelCache.set(modelBase64, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const decompressed = decompressFromBase64(modelBase64);
|
||||
decompressedModelCache.set(modelBase64, decompressed);
|
||||
decompressedModelCacheChars += cacheEntryChars(modelBase64, decompressed);
|
||||
|
||||
for (const [oldestKey, oldestValue] of decompressedModelCache) {
|
||||
if (
|
||||
decompressedModelCacheChars <= DECOMPRESSED_MODEL_CACHE_MAX_CHARS ||
|
||||
decompressedModelCache.size === 1
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
||||
decompressedModelCache.delete(oldestKey);
|
||||
decompressedModelCacheChars -= cacheEntryChars(oldestKey, oldestValue);
|
||||
}
|
||||
|
||||
return decompressed;
|
||||
}
|
||||
|
||||
export function useTrophyTermsAgreement() {
|
||||
const hasAgreedToTerms = useSyncExternalStore(
|
||||
subscribeToTermsAgreed,
|
||||
getTermsAgreedSnapshot,
|
||||
getTermsAgreedServerSnapshot,
|
||||
);
|
||||
|
||||
const agreeToTerms = () => {
|
||||
sessionStorage.setItem(TERMS_AGREED_SESSION_STORAGE_KEY, "true");
|
||||
for (const listener of termsAgreedListeners) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
|
||||
return { hasAgreedToTerms, agreeToTerms };
|
||||
}
|
||||
|
||||
const termsAgreedListeners = new Set<() => void>();
|
||||
|
||||
function subscribeToTermsAgreed(listener: () => void) {
|
||||
termsAgreedListeners.add(listener);
|
||||
return () => termsAgreedListeners.delete(listener);
|
||||
}
|
||||
|
||||
function getTermsAgreedSnapshot() {
|
||||
return sessionStorage.getItem(TERMS_AGREED_SESSION_STORAGE_KEY) === "true";
|
||||
}
|
||||
|
||||
function getTermsAgreedServerSnapshot() {
|
||||
return false;
|
||||
}
|
||||
|
||||
function cacheEntryChars(key: string, value: string | null) {
|
||||
return key.length + (value?.length ?? 0);
|
||||
}
|
||||
|
||||
export function useProgressiveRender(total: number, resetKey: string) {
|
||||
const [count, setCount] = useState(1);
|
||||
const prevKeyRef = useRef(resetKey);
|
||||
|
||||
if (prevKeyRef.current !== resetKey) {
|
||||
prevKeyRef.current = resetKey;
|
||||
setCount(1);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (count >= total) return;
|
||||
|
||||
const id = requestAnimationFrame(() => {
|
||||
setCount((c) => c + 1);
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [count, total]);
|
||||
|
||||
return count;
|
||||
}
|
||||
|
|
@ -156,6 +156,8 @@ export async function findProfileByIdentifier(
|
|||
"User.showDiscordUniqueName",
|
||||
"User.discordUniqueName",
|
||||
"User.favoriteBadgeIds",
|
||||
"User.favoriteTrophyIds",
|
||||
"User.hiddenTrophyIds",
|
||||
"User.patronTier",
|
||||
"PlusTier.tier as plusTier",
|
||||
"User.pronouns",
|
||||
|
|
@ -469,6 +471,18 @@ export function findAllPlusServerMembers() {
|
|||
.execute();
|
||||
}
|
||||
|
||||
export async function existingUserIds(userIds: Array<number>) {
|
||||
if (userIds.length === 0) return [];
|
||||
|
||||
const rows = await db
|
||||
.selectFrom("User")
|
||||
.select("User.id")
|
||||
.where("User.id", "in", userIds)
|
||||
.execute();
|
||||
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
export async function findChatUsersByUserIds(userIds: number[]) {
|
||||
const users = await db
|
||||
.selectFrom("User")
|
||||
|
|
@ -1050,6 +1064,8 @@ type UpdateProfileArgs = Pick<
|
|||
> & {
|
||||
weapons: Pick<TablesInsertable["UserWeapon"], "weaponSplId" | "isFavorite">[];
|
||||
favoriteBadgeIds?: number[] | null;
|
||||
favoriteTrophyIds?: number[] | null;
|
||||
hiddenTrophyIds?: number[] | null;
|
||||
customAvatarImgId?: number | null;
|
||||
};
|
||||
export function updateOwnProfile(args: UpdateProfileArgs) {
|
||||
|
|
@ -1104,6 +1120,12 @@ export function updateOwnProfile(args: UpdateProfileArgs) {
|
|||
favoriteBadgeIds: args.favoriteBadgeIds
|
||||
? JSON.stringify(args.favoriteBadgeIds)
|
||||
: null,
|
||||
favoriteTrophyIds: args.favoriteTrophyIds
|
||||
? JSON.stringify(args.favoriteTrophyIds)
|
||||
: null,
|
||||
hiddenTrophyIds: args.hiddenTrophyIds
|
||||
? JSON.stringify(args.hiddenTrophyIds)
|
||||
: null,
|
||||
showDiscordUniqueName: args.showDiscordUniqueName,
|
||||
commissionText: args.commissionText,
|
||||
commissionsOpen: args.commissionsOpen,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -25,5 +25,5 @@
|
|||
}
|
||||
|
||||
.participating {
|
||||
background-color: var(--color-accent);
|
||||
background-color: var(--color-text-accent);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { Placement } from "~/components/Placement";
|
|||
import type { Tables } from "~/db/tables";
|
||||
import { previewUrl } from "~/features/art/art-utils";
|
||||
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
|
||||
import { TrophyDisplay } from "~/features/trophies/components/TrophyDisplay";
|
||||
import { VodListing } from "~/features/vods/components/VodListing";
|
||||
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
|
||||
import { useAutoRerender } from "~/hooks/useAutoRerender";
|
||||
|
|
@ -78,6 +79,8 @@ export function Widget({
|
|||
<Markdown>{widget.data.bio}</Markdown>
|
||||
</article>
|
||||
);
|
||||
case "trophies-owned":
|
||||
return <TrophyDisplay trophies={widget.data} userId={user.id} />;
|
||||
case "badges-owned":
|
||||
return (
|
||||
<BadgeDisplay badges={widget.data} key={`badges-owned-${user.id}`} />
|
||||
|
|
|
|||
59
app/features/user-page/core/trophy-sorting.server.ts
Normal file
59
app/features/user-page/core/trophy-sorting.server.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { isSupporter } from "~/modules/permissions/utils";
|
||||
|
||||
interface SortTrophiesByFavoritesArgs<
|
||||
T extends Array<{ id: number; tier?: number | null }>,
|
||||
> {
|
||||
favoriteTrophyIds: number[] | null;
|
||||
hiddenTrophyIds: number[] | null;
|
||||
trophies: T;
|
||||
patronTier: number | null;
|
||||
}
|
||||
|
||||
export function sortTrophiesByFavorites<
|
||||
T extends Array<{ id: number; tier?: number | null }>,
|
||||
>({
|
||||
favoriteTrophyIds,
|
||||
hiddenTrophyIds,
|
||||
trophies,
|
||||
patronTier,
|
||||
}: SortTrophiesByFavoritesArgs<T>): {
|
||||
trophies: T;
|
||||
favoriteTrophyIds: number[] | null;
|
||||
} {
|
||||
const hiddenSet = new Set(hiddenTrophyIds ?? []);
|
||||
|
||||
let filteredFavoriteIds =
|
||||
favoriteTrophyIds?.filter(
|
||||
(trophyId) =>
|
||||
!hiddenSet.has(trophyId) &&
|
||||
trophies.some((trophy) => trophy.id === trophyId),
|
||||
) ?? null;
|
||||
|
||||
if (filteredFavoriteIds?.length === 0) {
|
||||
filteredFavoriteIds = null;
|
||||
}
|
||||
|
||||
filteredFavoriteIds = isSupporter({ patronTier })
|
||||
? filteredFavoriteIds
|
||||
: null;
|
||||
|
||||
const sortedTrophies = trophies.toSorted((a, b) => {
|
||||
const aIdx = filteredFavoriteIds?.indexOf(a.id) ?? -1;
|
||||
const bIdx = filteredFavoriteIds?.indexOf(b.id) ?? -1;
|
||||
|
||||
if (aIdx !== bIdx) {
|
||||
if (aIdx === -1) return 1;
|
||||
if (bIdx === -1) return -1;
|
||||
|
||||
return aIdx - bIdx;
|
||||
}
|
||||
|
||||
const aTier = a.tier ?? Number.MAX_SAFE_INTEGER;
|
||||
const bTier = b.tier ?? Number.MAX_SAFE_INTEGER;
|
||||
if (aTier !== bTier) return aTier - bTier;
|
||||
|
||||
return b.id - a.id;
|
||||
}) as T;
|
||||
|
||||
return { trophies: sortedTrophies, favoriteTrophyIds: filteredFavoriteIds };
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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" }),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Link, useLoaderData, useMatches } from "react-router";
|
|||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { FriendCodePopover } from "~/components/FriendCodePopover";
|
||||
import { BADGE } from "~/features/badges/badges-constants";
|
||||
import { SMALL_TROPHIES_PER_DISPLAY_PAGE } from "~/features/trophies/trophies-constants";
|
||||
import { existingImage } from "~/form/image-field";
|
||||
import { SendouForm } from "~/form/SendouForm";
|
||||
import { useHydrated } from "~/hooks/useHydrated";
|
||||
|
|
@ -41,6 +42,13 @@ export default function UserEditPage() {
|
|||
hue: badge.hue,
|
||||
}));
|
||||
|
||||
const trophyOptions = data.ownedTrophies.map((trophy) => ({
|
||||
id: trophy.id,
|
||||
name: trophy.name,
|
||||
model: trophy.model,
|
||||
tier: trophy.tier,
|
||||
}));
|
||||
|
||||
const defaultValues = {
|
||||
customAvatar: existingImage(
|
||||
data.user.customAvatarImgId,
|
||||
|
|
@ -54,6 +62,8 @@ export default function UserEditPage() {
|
|||
battlefy: data.user.battlefy ?? "",
|
||||
country: data.user.country ?? null,
|
||||
favoriteBadgeIds: data.favoriteBadgeIds ?? [],
|
||||
favoriteTrophyIds: data.favoriteTrophyIds ?? [],
|
||||
hiddenTrophyIds: data.hiddenTrophyIds ?? [],
|
||||
weapons: data.user.weapons.map((w) => ({
|
||||
id: w.weaponSplId,
|
||||
isFavorite: Boolean(w.isFavorite),
|
||||
|
|
@ -93,6 +103,16 @@ export default function UserEditPage() {
|
|||
}
|
||||
/>
|
||||
) : null}
|
||||
{isSupporter && data.ownedTrophies.length >= 2 ? (
|
||||
<FormField
|
||||
name="favoriteTrophyIds"
|
||||
options={trophyOptions}
|
||||
maxCount={SMALL_TROPHIES_PER_DISPLAY_PAGE}
|
||||
/>
|
||||
) : null}
|
||||
{data.ownedTrophies.length >= 1 ? (
|
||||
<FormField name="hiddenTrophyIds" options={trophyOptions} />
|
||||
) : null}
|
||||
<FormField name="weapons" />
|
||||
<FormField name="bio" />
|
||||
{data.discordUniqueName ? (
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { TwitchIcon } from "~/components/icons/Twitch";
|
|||
import { YouTubeIcon } from "~/components/icons/YouTube";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
|
||||
import { TrophyDisplay } from "~/features/trophies/components/TrophyDisplay";
|
||||
import { UserCard } from "~/features/user-card/components/UserCard";
|
||||
import { modesShort } from "~/modules/in-game-lists/modes";
|
||||
import { countryCodeToTranslatedName } from "~/utils/i18n";
|
||||
|
|
@ -56,6 +57,7 @@ export const handle: SendouRouteHandle = {
|
|||
"weapons",
|
||||
"gear",
|
||||
"game-badges",
|
||||
"trophies",
|
||||
],
|
||||
};
|
||||
|
||||
|
|
@ -231,7 +233,17 @@ export function OldUserInfoPage() {
|
|||
<ExtraInfos />
|
||||
<WeaponPool />
|
||||
<TopPlacements />
|
||||
<BadgeDisplay badges={data.user.badges} key={layoutData.user.id} />
|
||||
{data.trophies.length > 0 ? (
|
||||
<TrophyDisplay
|
||||
trophies={data.trophies}
|
||||
userId={layoutData.user.id}
|
||||
key={`trophies-${layoutData.user.id}`}
|
||||
/>
|
||||
) : null}
|
||||
<BadgeDisplay
|
||||
badges={data.user.badges}
|
||||
key={`badges-${layoutData.user.id}`}
|
||||
/>
|
||||
{data.user.bio && <article>{data.user.bio}</article>}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { TeamSearchFormField } from "./fields/TeamSearchFormField";
|
|||
import { TextareaFormField } from "./fields/TextareaFormField";
|
||||
import { TimeRangeFormField } from "./fields/TimeRangeFormField";
|
||||
import { TournamentSearchFormField } from "./fields/TournamentSearchFormField";
|
||||
import { TrophiesFormField } from "./fields/TrophiesFormField";
|
||||
import { UserSearchFormField } from "./fields/UserSearchFormField";
|
||||
import {
|
||||
WeaponPoolFormField,
|
||||
|
|
@ -38,6 +39,7 @@ import type {
|
|||
SelectOption,
|
||||
TeamSearchFieldOptions,
|
||||
TournamentSearchFieldOptions,
|
||||
TrophyOption,
|
||||
UserSearchFieldOptions,
|
||||
} from "./types";
|
||||
import {
|
||||
|
|
@ -557,6 +559,22 @@ export function FormField({
|
|||
);
|
||||
}
|
||||
|
||||
if (formField.type === "trophies") {
|
||||
if (!options) {
|
||||
throw new Error("Trophies form field requires options prop");
|
||||
}
|
||||
return (
|
||||
<TrophiesFormField
|
||||
{...commonProps}
|
||||
{...formField}
|
||||
value={value as number[]}
|
||||
onChange={handleChange as (v: number[]) => void}
|
||||
options={options as TrophyOption[]}
|
||||
{...(maxCount !== undefined ? { maxCount } : {})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (formField.type === "stage-select") {
|
||||
return (
|
||||
<StageSelectFormField
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import type {
|
|||
SelectOption,
|
||||
TeamSearchFieldOptions,
|
||||
TournamentSearchFieldOptions,
|
||||
TrophyOption,
|
||||
} from "./types";
|
||||
|
||||
export const formRegistry = z.registry<FormField>();
|
||||
|
|
@ -838,6 +839,23 @@ export function badges(
|
|||
}) as z.ZodArray<typeof id> & FieldWithOptions<BadgeOption[]>;
|
||||
}
|
||||
|
||||
export function trophies(
|
||||
args: WithTypedTranslationKeys<
|
||||
Omit<Extract<FormField, { type: "trophies" }>, "type" | "initialValue">
|
||||
>,
|
||||
) {
|
||||
return z
|
||||
.array(id)
|
||||
.max(args.maxCount ?? 100)
|
||||
.register(formRegistry, {
|
||||
...args,
|
||||
label: prefixKey(args.label),
|
||||
bottomText: prefixKey(args.bottomText),
|
||||
type: "trophies",
|
||||
initialValue: [],
|
||||
}) as z.ZodArray<typeof id> & FieldWithOptions<TrophyOption[]>;
|
||||
}
|
||||
|
||||
export function stageSelect(
|
||||
args: WithTypedTranslationKeys<
|
||||
Omit<
|
||||
|
|
|
|||
43
app/form/fields/TrophiesFormField.tsx
Normal file
43
app/form/fields/TrophiesFormField.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import * as React from "react";
|
||||
import { TrophiesSelector } from "~/features/trophies/components/TrophiesSelector";
|
||||
import type { FormFieldProps, TrophyOption } from "../types";
|
||||
import { FormFieldWrapper } from "./FormFieldWrapper";
|
||||
|
||||
type TrophiesFormFieldProps = Omit<FormFieldProps<"trophies">, "onBlur"> & {
|
||||
value: number[];
|
||||
onChange: (value: number[]) => void;
|
||||
onBlur?: () => void;
|
||||
options: TrophyOption[];
|
||||
};
|
||||
|
||||
export function TrophiesFormField({
|
||||
name,
|
||||
label,
|
||||
bottomText,
|
||||
error,
|
||||
maxCount,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
options,
|
||||
}: TrophiesFormFieldProps) {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormFieldWrapper
|
||||
id={id}
|
||||
name={name}
|
||||
label={label}
|
||||
error={error}
|
||||
bottomText={bottomText}
|
||||
>
|
||||
<TrophiesSelector
|
||||
options={options}
|
||||
selectedTrophies={value}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
maxCount={maxCount}
|
||||
/>
|
||||
</FormFieldWrapper>
|
||||
);
|
||||
}
|
||||
|
|
@ -163,6 +163,10 @@ interface FormFieldBadges<T extends string> extends FormFieldBase<T> {
|
|||
maxCount?: number;
|
||||
}
|
||||
|
||||
interface FormFieldTrophies<T extends string> extends FormFieldBase<T> {
|
||||
maxCount?: number;
|
||||
}
|
||||
|
||||
interface FormFieldSelectDynamic<T extends string> extends FormFieldBase<T> {
|
||||
clearable: boolean;
|
||||
searchable?: boolean;
|
||||
|
|
@ -205,6 +209,7 @@ export type FormField<V extends string = string> =
|
|||
| FormFieldTournamentSearch<"tournament-search">
|
||||
| FormFieldTeamSearch<"team-search">
|
||||
| FormFieldBadges<"badges">
|
||||
| FormFieldTrophies<"trophies">
|
||||
| FormFieldStageSelect<"stage-select">
|
||||
| FormFieldWeaponSelect<"weapon-select">;
|
||||
|
||||
|
|
@ -237,6 +242,13 @@ export type BadgeOption = {
|
|||
hue: number | null;
|
||||
};
|
||||
|
||||
export type TrophyOption = {
|
||||
id: number;
|
||||
name: string;
|
||||
model: string;
|
||||
tier: number | null;
|
||||
};
|
||||
|
||||
export type SelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { z } from "zod";
|
||||
import { ServerConfig } from "~/config.server";
|
||||
import { STAFF_DISCORD_IDS } from "~/features/admin/admin-constants";
|
||||
import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { fetchWithTimeout } from "~/utils/fetch";
|
||||
|
|
@ -46,6 +47,7 @@ export async function updatePatreonData(): Promise<void> {
|
|||
];
|
||||
|
||||
await UserRepository.updatePatronData(patronsWithMods);
|
||||
await TrophyRepository.syncSpecialTrophies();
|
||||
}
|
||||
|
||||
const MAX_RETRIES = 10;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user