mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-26 21:26:01 -05:00
Merge origin/main into test-refactor
Ports the trophies feature's seeding and tests to the new factory setup: - TrophyFactory for trophies and pending submissions, TournamentFactory awarding a tournament's trophy prize to its winner on finalization - dev/trophies.ts replacing the hand-written trophy rows of the old seed, with trophy prizes attached to the dev tournaments - TrophyRepository tests built from factories instead of raw inserts - trophies e2e spec seeding its own data through factories and driving the pages through page objects
This commit is contained in:
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,
|
||||
},
|
||||
{
|
||||
|
||||
5
app/db/seed/data/trophies.json
Normal file
5
app/db/seed/data/trophies.json
Normal file
File diff suppressed because one or more lines are too long
@@ -2,6 +2,7 @@ import { sub } from "date-fns";
|
||||
import type { TournamentSettings } from "~/db/tables-json";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import { BANNED_MAPS } from "~/features/match-profile/banned-maps";
|
||||
import type { TournamentTierNumber } from "~/features/tournament/core/tiering";
|
||||
import { rankedModesShort } from "~/modules/in-game-lists/modes";
|
||||
import { stageIds } from "~/modules/in-game-lists/stage-ids";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
@@ -14,6 +15,7 @@ import * as TournamentStreamerFactory from "../factories/TournamentStreamerFacto
|
||||
import * as TournamentTeamFactory from "../factories/TournamentTeamFactory";
|
||||
import type { SeededBadges } from "./badges";
|
||||
import type { SeededOrganization } from "./organizations";
|
||||
import type { SeededTrophies } from "./trophies";
|
||||
import type { SeededUsers } from "./users";
|
||||
|
||||
// xxx: rethink what should be here
|
||||
@@ -109,25 +111,39 @@ export async function seedTournaments({
|
||||
users,
|
||||
organizations,
|
||||
badges,
|
||||
trophies,
|
||||
}: {
|
||||
users: SeededUsers;
|
||||
organizations: SeededOrganization[];
|
||||
badges: SeededBadges;
|
||||
trophies: SeededTrophies;
|
||||
}) {
|
||||
const rosters = rosterBuilder(users);
|
||||
|
||||
await seedRegOpenDoubleElim({ users, organizations, rosters });
|
||||
await seedRegOpenDoubleElim({ users, organizations, rosters, trophies });
|
||||
await seedRegClosedRoundRobin({ users, organizations, rosters });
|
||||
await seedMidBracketDoubleElim({ users, organizations, rosters });
|
||||
await seedSwissUnderway({ users, organizations, rosters });
|
||||
await seedPlayedAwaitingFinalization({ users, organizations, rosters });
|
||||
await seedFinalizedSingleElim({ users, organizations, badges, rosters });
|
||||
await seedFinalizedSingleElim({
|
||||
users,
|
||||
organizations,
|
||||
badges,
|
||||
rosters,
|
||||
trophies,
|
||||
});
|
||||
await seedFinalizedRoundRobin({ users, organizations, rosters });
|
||||
await seedRegOpenOneVersusOne({ users });
|
||||
await seedFinalizedTwoVersusTwo({ users, organizations, rosters });
|
||||
await seedInvitational({ users, rosters });
|
||||
|
||||
await seedHistoricalTournaments({ users, organizations, badges, rosters });
|
||||
await seedHistoricalTournaments({
|
||||
users,
|
||||
organizations,
|
||||
badges,
|
||||
rosters,
|
||||
trophies,
|
||||
});
|
||||
}
|
||||
|
||||
type Ctx = {
|
||||
@@ -136,8 +152,13 @@ type Ctx = {
|
||||
rosters: ReturnType<typeof rosterBuilder>;
|
||||
};
|
||||
|
||||
/** #1 double elim, 16-team cap, TO maps — reg open, partial rosters. */
|
||||
async function seedRegOpenDoubleElim({ users, organizations, rosters }: Ctx) {
|
||||
/** #1 double elim, 16-team cap, TO maps — reg open, partial rosters, trophy prize. */
|
||||
async function seedRegOpenDoubleElim({
|
||||
users,
|
||||
organizations,
|
||||
rosters,
|
||||
trophies,
|
||||
}: Ctx & { trophies: SeededTrophies }) {
|
||||
const tournament = await TournamentFactory.create({
|
||||
name: nameFor(0),
|
||||
authorId: users.adminId,
|
||||
@@ -148,6 +169,7 @@ async function seedRegOpenDoubleElim({ users, organizations, rosters }: Ctx) {
|
||||
mapPoolMaps: toSetMapPool(),
|
||||
bracketProgression: DOUBLE_ELIMINATION,
|
||||
enableSubs: true,
|
||||
trophyId: trophies.ids[0],
|
||||
});
|
||||
|
||||
const teamRosters = rosters.take({ teamCount: 10, teamSize: 4 });
|
||||
@@ -260,12 +282,13 @@ async function seedPlayedAwaitingFinalization({ users, rosters }: Ctx) {
|
||||
await TournamentFactory.playOut(tournament.id);
|
||||
}
|
||||
|
||||
/** #6 single elim with third place match, TO maps — finalized, badge awarded. */
|
||||
/** #6 single elim with third place match, TO maps — finalized, badge and trophy awarded. */
|
||||
async function seedFinalizedSingleElim({
|
||||
users,
|
||||
badges,
|
||||
rosters,
|
||||
}: Ctx & { badges: SeededBadges }) {
|
||||
trophies,
|
||||
}: Ctx & { badges: SeededBadges; trophies: SeededTrophies }) {
|
||||
const badgeId = badges.ids[0];
|
||||
|
||||
const tournament = await TournamentFactory.create({
|
||||
@@ -278,6 +301,7 @@ async function seedFinalizedSingleElim({
|
||||
bracketProgression: SINGLE_ELIMINATION,
|
||||
thirdPlaceMatch: true,
|
||||
badges: [badgeId],
|
||||
trophyId: trophies.ids[0],
|
||||
});
|
||||
|
||||
await registerTeams({
|
||||
@@ -379,7 +403,8 @@ async function seedHistoricalTournaments({
|
||||
users,
|
||||
badges,
|
||||
rosters,
|
||||
}: Ctx & { badges: SeededBadges }) {
|
||||
trophies,
|
||||
}: Ctx & { badges: SeededBadges; trophies: SeededTrophies }) {
|
||||
for (let i = 0; i < HISTORICAL_COUNT; i++) {
|
||||
const progression = faker.helpers.weightedArrayElement([
|
||||
{ value: DOUBLE_ELIMINATION, weight: 5 },
|
||||
@@ -394,17 +419,21 @@ async function seedHistoricalTournaments({
|
||||
: sub(new Date(), { months: 1 + (i % 8), days: (i * 7) % 28 });
|
||||
const badgeId = i % 3 === 0 ? badges.ids[i % badges.ids.length] : undefined;
|
||||
|
||||
const tournament = await TournamentFactory.create({
|
||||
name: nameFor(10 + i),
|
||||
authorId: faker.helpers.arrayElement(users.showcaseIds),
|
||||
startTimes: [dateToDatabaseTimestamp(startsAt)],
|
||||
mapPickingStyle: isRecent ? "AUTO_SZ" : "AUTO_ALL",
|
||||
mapPoolMaps: isRecent ? undefined : tiebreakerMapPool(),
|
||||
bracketProgression: progression,
|
||||
teamsPerGroup: 4,
|
||||
isRanked: isRecent,
|
||||
badges: badgeId ? [badgeId] : [],
|
||||
});
|
||||
const tournament = await TournamentFactory.create(
|
||||
{
|
||||
name: nameFor(10 + i),
|
||||
authorId: faker.helpers.arrayElement(users.showcaseIds),
|
||||
startTimes: [dateToDatabaseTimestamp(startsAt)],
|
||||
mapPickingStyle: isRecent ? "AUTO_SZ" : "AUTO_ALL",
|
||||
mapPoolMaps: isRecent ? undefined : tiebreakerMapPool(),
|
||||
bracketProgression: progression,
|
||||
teamsPerGroup: 4,
|
||||
isRanked: isRecent,
|
||||
badges: badgeId ? [badgeId] : [],
|
||||
trophyId: trophies.ids[i % trophies.ids.length],
|
||||
},
|
||||
{ tier: ((i % 3) + 1) as TournamentTierNumber },
|
||||
);
|
||||
|
||||
await registerTeams({
|
||||
tournamentId: tournament.id,
|
||||
|
||||
125
app/db/seed/dev/trophies.ts
Normal file
125
app/db/seed/dev/trophies.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
|
||||
import {
|
||||
SUPPORTER_TROPHY_CODE,
|
||||
XP_TROPHY_CODE_PREFIX,
|
||||
} from "~/features/trophies/trophies-constants";
|
||||
import { faker } from "../core/faker";
|
||||
import trophies from "../data/trophies.json";
|
||||
import * as TrophyFactory from "../factories/TrophyFactory";
|
||||
import type { SeededOrganization } from "./organizations";
|
||||
import type { SeededUsers } from "./users";
|
||||
|
||||
const PENDING_COUNT = 5;
|
||||
const PARTIALLY_APPROVED_COUNT = 2;
|
||||
const ACCEPTED_COUNT = 3;
|
||||
const DECLINED_COUNT = 3;
|
||||
|
||||
export type SeededTrophies = {
|
||||
/** Ids of the trophies tournaments can be given as a prize. */
|
||||
ids: number[];
|
||||
};
|
||||
|
||||
export async function seedTrophies({
|
||||
users,
|
||||
organizations,
|
||||
}: {
|
||||
users: SeededUsers;
|
||||
organizations: SeededOrganization[];
|
||||
}): Promise<SeededTrophies> {
|
||||
const ids: number[] = [];
|
||||
for (const [name, model] of Object.entries(trophies)) {
|
||||
const trophy = await TrophyFactory.create({
|
||||
name,
|
||||
model,
|
||||
organizationId: organizations[0]?.id ?? null,
|
||||
creatorId: users.adminId,
|
||||
managerId: users.nzapId,
|
||||
});
|
||||
|
||||
ids.push(trophy.id);
|
||||
}
|
||||
|
||||
await seedPendingTrophies({ users, organizations });
|
||||
|
||||
return { ids };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the trophies awarded off something other than a tournament win and hands
|
||||
* them to everybody eligible, as the nightly sync does. Runs last of the seed: who
|
||||
* is eligible follows from the patrons and X Rank placements seeded before it.
|
||||
*/
|
||||
export async function seedSpecialTrophies() {
|
||||
const models = TrophyFactory.MODELS;
|
||||
|
||||
await TrophyFactory.create({
|
||||
name: "Supporter",
|
||||
model: models[0],
|
||||
code: SUPPORTER_TROPHY_CODE,
|
||||
});
|
||||
await TrophyFactory.create({
|
||||
name: "3000 X Power",
|
||||
model: models[1],
|
||||
code: `${XP_TROPHY_CODE_PREFIX}3000`,
|
||||
});
|
||||
await TrophyFactory.create({
|
||||
name: "2600 X Power",
|
||||
model: models[2],
|
||||
code: `${XP_TROPHY_CODE_PREFIX}2600`,
|
||||
});
|
||||
|
||||
await TrophyRepository.syncSpecialTrophies();
|
||||
}
|
||||
|
||||
async function seedPendingTrophies({
|
||||
users,
|
||||
organizations,
|
||||
}: {
|
||||
users: SeededUsers;
|
||||
organizations: SeededOrganization[];
|
||||
}) {
|
||||
const organizationId = organizations[0].id;
|
||||
const submitterIds = users.showcaseIds.slice(0, 10);
|
||||
const submission = (index: number) => ({
|
||||
organizationId,
|
||||
submitterUserId: faker.helpers.arrayElement(submitterIds),
|
||||
description: faker.lorem.sentence(),
|
||||
name: `Pending trophy ${index + 1}`,
|
||||
});
|
||||
|
||||
await TrophyFactory.createManyPending(PENDING_COUNT, (index) =>
|
||||
submission(index),
|
||||
);
|
||||
|
||||
await TrophyFactory.createManyPending(
|
||||
PARTIALLY_APPROVED_COUNT,
|
||||
(index) => ({
|
||||
...submission(index),
|
||||
name: `Partially approved trophy ${index + 1}`,
|
||||
}),
|
||||
{ approverUserIds: [users.adminId] },
|
||||
);
|
||||
|
||||
await TrophyFactory.createManyPending(
|
||||
ACCEPTED_COUNT,
|
||||
(index) => ({
|
||||
...submission(index),
|
||||
name: `Accepted trophy ${index + 1}`,
|
||||
}),
|
||||
{ approverUserIds: [users.adminId, users.staffId] },
|
||||
);
|
||||
|
||||
await TrophyFactory.createManyPending(
|
||||
DECLINED_COUNT,
|
||||
(index) => ({
|
||||
...submission(index),
|
||||
name: `Declined trophy ${index + 1}`,
|
||||
}),
|
||||
{
|
||||
declinedBy: {
|
||||
userId: users.adminId,
|
||||
reason: faker.lorem.sentence(),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -355,33 +355,38 @@ async function playOutMatch(tournamentId: number, match: PlayedMatch) {
|
||||
async function finalize(tournamentId: number) {
|
||||
const tournament = await tournamentFromDB({ tournamentId, user: undefined });
|
||||
|
||||
const event = await CalendarRepository.findById(tournament.ctx.eventId, {
|
||||
includeBadgePrizes: true,
|
||||
includeTrophy: true,
|
||||
});
|
||||
const winner = winningTeam(tournament);
|
||||
|
||||
await finalizeTournament({
|
||||
tournament,
|
||||
badgeReceivers: await winnersAsBadgeReceivers(tournament),
|
||||
badgeReceivers: event?.badgePrizes?.length
|
||||
? event.badgePrizes.map((badge) => ({
|
||||
badgeId: badge.id,
|
||||
tournamentTeamId: winner.team.id,
|
||||
userIds: winner.team.members.map((member) => member.userId),
|
||||
}))
|
||||
: undefined,
|
||||
trophyReceiver: event?.trophy
|
||||
? {
|
||||
trophyId: event.trophy.id,
|
||||
userIds: winner.team.members.map((member) => member.userId),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** The tournament's badges all go to the winning team, as the organizer typically assigns them. */
|
||||
async function winnersAsBadgeReceivers(
|
||||
tournament: Awaited<ReturnType<typeof tournamentFromDB>>,
|
||||
) {
|
||||
const badges = (
|
||||
await CalendarRepository.findById(tournament.ctx.eventId, {
|
||||
includeBadgePrizes: true,
|
||||
})
|
||||
)?.badgePrizes;
|
||||
if (!badges?.length) return undefined;
|
||||
|
||||
/** Whose the tournament's prizes are, as the organizer typically assigns them. */
|
||||
function winningTeam(tournament: Awaited<ReturnType<typeof tournamentFromDB>>) {
|
||||
const winner = Standings.flattenStandings(
|
||||
Standings.tournamentStandings(tournament),
|
||||
).find((standing) => standing.placement === 1);
|
||||
invariant(winner, "Tournament to award badges for has no winner");
|
||||
invariant(winner, "Tournament to award prizes for has no winner");
|
||||
|
||||
return badges.map((badge) => ({
|
||||
badgeId: badge.id,
|
||||
tournamentTeamId: winner.team.id,
|
||||
userIds: winner.team.members.map((member) => member.userId),
|
||||
}));
|
||||
return winner;
|
||||
}
|
||||
|
||||
async function findMatch(matchId: number) {
|
||||
|
||||
100
app/db/seed/factories/TrophyFactory.ts
Normal file
100
app/db/seed/factories/TrophyFactory.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { db } from "~/db/sql";
|
||||
import type { TablesInsertable } from "~/db/tables";
|
||||
import type { TournamentTierNumber } from "~/features/tournament/core/tiering";
|
||||
import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
|
||||
import { defineFactory } from "../core/defineFactory";
|
||||
// the e2e process loads this factory as plain ESM, where the attribute is required
|
||||
import trophies from "../data/trophies.json" with { type: "json" };
|
||||
|
||||
/** Compressed model states of real trophies, one of which every trophy is given. */
|
||||
export const MODELS = Object.values(trophies);
|
||||
|
||||
type Win = {
|
||||
userId: number;
|
||||
tournamentId: number;
|
||||
tier?: TournamentTierNumber | null;
|
||||
};
|
||||
|
||||
type Options = {
|
||||
/** Tournaments the trophy has been won in, one row per winner of each. */
|
||||
wins?: Win[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates trophies. A trophy only comes into existence in production once a
|
||||
* submission has gathered its approvals, which `createPending` covers — one that
|
||||
* merely has to exist is written directly.
|
||||
*/
|
||||
export const { create, createMany } = defineFactory({
|
||||
defaults: ({ seq }) => ({
|
||||
name: `Trophy ${seq}`,
|
||||
model: MODELS[seq % MODELS.length],
|
||||
code: null,
|
||||
organizationId: null,
|
||||
creatorId: null,
|
||||
managerId: null,
|
||||
}),
|
||||
insert: (args: TablesInsertable["Trophy"]) =>
|
||||
db
|
||||
.insertInto("Trophy")
|
||||
.values(args)
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow(),
|
||||
applyOptions: async (trophy, { wins }: Options) => {
|
||||
if (!wins?.length) return;
|
||||
|
||||
// written directly because production awards a trophy only by finalizing the
|
||||
// tournament it was the prize of, which `TournamentFactory` plays out
|
||||
await db
|
||||
.insertInto("TrophyOwner")
|
||||
.values(
|
||||
wins.map((win) => ({
|
||||
trophyId: trophy.id,
|
||||
userId: win.userId,
|
||||
tournamentId: win.tournamentId,
|
||||
tier: win.tier ?? null,
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
},
|
||||
});
|
||||
|
||||
type PendingOptions = {
|
||||
/** Who approves the submission; enough of them and the trophy is created. */
|
||||
approverUserIds?: number[];
|
||||
/** Who turns the submission down, and why. */
|
||||
declinedBy?: { userId: number; reason: string };
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates trophy submissions awaiting review. The options review the submission the
|
||||
* way the review page does, so one can be brought to any point of its life.
|
||||
*/
|
||||
export const { create: createPending, createMany: createManyPending } =
|
||||
defineFactory({
|
||||
defaults: ({ seq }) => ({
|
||||
name: `Pending trophy ${seq}`,
|
||||
model: MODELS[seq % MODELS.length],
|
||||
description: "",
|
||||
}),
|
||||
insert: TrophyRepository.createPending,
|
||||
applyOptions: async (
|
||||
pending,
|
||||
{ approverUserIds, declinedBy }: PendingOptions,
|
||||
) => {
|
||||
for (const userId of approverUserIds ?? []) {
|
||||
await TrophyRepository.addApproval({
|
||||
pendingTrophyId: pending.id,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
if (declinedBy) {
|
||||
await TrophyRepository.declinePending({
|
||||
id: pending.id,
|
||||
reason: declinedBy.reason,
|
||||
declinedByUserId: declinedBy.userId,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import { seedScrimsAndLFG } from "./dev/scrims-lfg";
|
||||
import { seedSendouQ } from "./dev/sendouq";
|
||||
import { seedTeams } from "./dev/teams";
|
||||
import { seedTournaments } from "./dev/tournaments";
|
||||
import { seedSpecialTrophies, seedTrophies } from "./dev/trophies";
|
||||
import { seedUsers } from "./dev/users";
|
||||
import { seedVods } from "./dev/vods";
|
||||
|
||||
@@ -23,15 +24,21 @@ export async function seed() {
|
||||
const users = await runModule(() => seedUsers());
|
||||
const badges = await runModule(() => seedBadges(users));
|
||||
const organizations = await runModule(() => seedOrganizations(users));
|
||||
const trophies = await runModule(() =>
|
||||
seedTrophies({ users, organizations }),
|
||||
);
|
||||
const teams = await runModule(() => seedTeams(users));
|
||||
await runModule(() => seedCalendarEvents(users, badges));
|
||||
await runModule(() => seedTournaments({ users, organizations, badges }));
|
||||
await runModule(() =>
|
||||
seedTournaments({ users, organizations, badges, trophies }),
|
||||
);
|
||||
const sendouq = await runModule(() => seedSendouQ(users, teams));
|
||||
await runModule(() => seedPlus(users));
|
||||
await runModule(() => seedBuilds(users));
|
||||
await runModule(() => seedScrimsAndLFG(users, teams));
|
||||
await runModule(() => seedVods(users));
|
||||
await runModule(() => seedMisc({ users, sendouq }));
|
||||
await runModule(() => seedSpecialTrophies());
|
||||
|
||||
clearAllTournamentDataCache();
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -286,18 +287,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,
|
||||
@@ -306,6 +308,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",
|
||||
|
||||
@@ -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-testid="badge-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");
|
||||
|
||||
@@ -3,14 +3,19 @@ import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as BadgeRepository from "~/features/badges/BadgeRepository.server";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import { notify } from "~/features/notifications/core/notify.server";
|
||||
import * as Standings from "~/features/tournament/core/Standings";
|
||||
import { finalizeTournament } from "~/features/tournament-bracket/core/finalizeTournament.server";
|
||||
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
|
||||
import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server";
|
||||
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 invariant from "~/utils/invariant";
|
||||
import { logger } from "~/utils/logger";
|
||||
import {
|
||||
@@ -37,14 +42,38 @@ 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");
|
||||
|
||||
const trophyReceiver = event.trophy ? (data.trophyReceiver ?? null) : null;
|
||||
if (event.trophy) {
|
||||
const trophyReceiverValid = requireValidTrophyReceiver({
|
||||
trophyReceiver,
|
||||
trophy: event.trophy,
|
||||
finalStandings: Standings.flattenStandings(
|
||||
Standings.tournamentStandings(tournament),
|
||||
),
|
||||
tournament,
|
||||
});
|
||||
if (!trophyReceiverValid) errorToast("Invalid trophy receiver");
|
||||
}
|
||||
|
||||
await finalizeTournament({
|
||||
tournament,
|
||||
badgeReceivers: data.badgeReceivers ?? undefined,
|
||||
trophyReceiver: trophyReceiver ?? undefined,
|
||||
});
|
||||
|
||||
if (data.badgeReceivers) {
|
||||
@@ -55,6 +84,12 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
notifyBadgeReceivers(data.badgeReceivers);
|
||||
}
|
||||
|
||||
if (trophyReceiver) {
|
||||
logger.info(
|
||||
`Trophy receiver for tournament id ${tournamentId}: ${JSON.stringify(trophyReceiver)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ensure RunningTournament = sidebar updates
|
||||
await tournamentFromDB({ tournamentId, user });
|
||||
|
||||
@@ -64,17 +99,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,
|
||||
@@ -90,6 +123,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) {
|
||||
|
||||
@@ -10,21 +10,26 @@ import { refreshTentativeTiersCache } from "~/features/tournament-organization/c
|
||||
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { logger } from "~/utils/logger";
|
||||
import type { TournamentBadgeReceivers } from "../tournament-bracket-schemas.server";
|
||||
import type {
|
||||
TournamentBadgeReceivers,
|
||||
TournamentTrophyReceiver,
|
||||
} from "../tournament-bracket-schemas.server";
|
||||
import { summaryRatingTargets, tournamentSummary } from "./summarizer.server";
|
||||
import type { Tournament } from "./Tournament";
|
||||
import { clearTournamentDataCache } from "./Tournament.server";
|
||||
|
||||
// xxx: logs when seeding
|
||||
|
||||
/** Finalizes a fully played tournament with a real summary: results, skills, badges
|
||||
* and leaderboard entries. */
|
||||
/** Finalizes a fully played tournament with a real summary: results, skills, badges,
|
||||
* trophies and leaderboard entries. */
|
||||
export async function finalizeTournament({
|
||||
tournament,
|
||||
badgeReceivers,
|
||||
trophyReceiver,
|
||||
}: {
|
||||
tournament: Tournament;
|
||||
badgeReceivers?: TournamentBadgeReceivers;
|
||||
trophyReceiver?: TournamentTrophyReceiver;
|
||||
}) {
|
||||
const tournamentId = tournament.ctx.id;
|
||||
|
||||
@@ -73,6 +78,7 @@ export async function finalizeTournament({
|
||||
summary,
|
||||
season,
|
||||
badgeReceivers,
|
||||
trophyReceiver,
|
||||
});
|
||||
} else {
|
||||
logger.info(
|
||||
|
||||
@@ -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,
|
||||
|
||||
269
app/features/trophies/TrophyRepository.server.test.ts
Normal file
269
app/features/trophies/TrophyRepository.server.test.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
|
||||
import * as TournamentOrganizationFactory from "~/db/seed/factories/TournamentOrganizationFactory";
|
||||
import * as TrophyFactory from "~/db/seed/factories/TrophyFactory";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import { db } from "~/db/sql";
|
||||
import type { TournamentTierNumber } from "~/features/tournament/core/tiering";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import * as TrophyRepository from "./TrophyRepository.server";
|
||||
|
||||
describe("trophy approvals", () => {
|
||||
let pendingTrophyId: number;
|
||||
let reviewerIds: number[];
|
||||
|
||||
beforeEach(async () => {
|
||||
const submitter = await UserFactory.create();
|
||||
reviewerIds = (await UserFactory.createMany(3)).map((user) => user.id);
|
||||
const organization = await TournamentOrganizationFactory.create({
|
||||
ownerId: submitter.id,
|
||||
});
|
||||
|
||||
const pending = await TrophyFactory.createPending({
|
||||
organizationId: organization.id,
|
||||
submitterUserId: submitter.id,
|
||||
});
|
||||
pendingTrophyId = pending.id;
|
||||
});
|
||||
|
||||
test("creates the trophy exactly once when approvals exceed the required count", async () => {
|
||||
const [first, second, third] = reviewerIds;
|
||||
|
||||
expect(
|
||||
await TrophyRepository.addApproval({ pendingTrophyId, userId: first }),
|
||||
).toBe(null);
|
||||
|
||||
const accepted = await TrophyRepository.addApproval({
|
||||
pendingTrophyId,
|
||||
userId: second,
|
||||
});
|
||||
expect(accepted?.id).toBeTypeOf("number");
|
||||
|
||||
expect(
|
||||
await TrophyRepository.addApproval({ pendingTrophyId, userId: third }),
|
||||
).toBe(null);
|
||||
|
||||
expect(await trophyCount()).toBe(1);
|
||||
});
|
||||
|
||||
test("ignores repeated approvals from the same user", async () => {
|
||||
await TrophyRepository.addApproval({
|
||||
pendingTrophyId,
|
||||
userId: reviewerIds[0],
|
||||
});
|
||||
|
||||
expect(
|
||||
await TrophyRepository.addApproval({
|
||||
pendingTrophyId,
|
||||
userId: reviewerIds[0],
|
||||
}),
|
||||
).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: reviewerIds[0],
|
||||
});
|
||||
await TrophyRepository.addApproval({
|
||||
pendingTrophyId,
|
||||
userId: reviewerIds[1],
|
||||
});
|
||||
|
||||
expect(
|
||||
await TrophyRepository.addApproval({
|
||||
pendingTrophyId,
|
||||
userId: reviewerIds[0],
|
||||
}),
|
||||
).toBe(null);
|
||||
|
||||
expect(await trophyCount()).toBe(1);
|
||||
});
|
||||
|
||||
test("declines a pending trophy that is not accepted", async () => {
|
||||
await TrophyRepository.addApproval({
|
||||
pendingTrophyId,
|
||||
userId: reviewerIds[0],
|
||||
});
|
||||
|
||||
expect(
|
||||
await TrophyRepository.declinePending({
|
||||
id: pendingTrophyId,
|
||||
reason: "reason",
|
||||
declinedByUserId: reviewerIds[1],
|
||||
}),
|
||||
).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: reviewerIds[0],
|
||||
});
|
||||
await TrophyRepository.addApproval({
|
||||
pendingTrophyId,
|
||||
userId: reviewerIds[1],
|
||||
});
|
||||
|
||||
expect(
|
||||
await TrophyRepository.declinePending({
|
||||
id: pendingTrophyId,
|
||||
reason: "reason",
|
||||
declinedByUserId: reviewerIds[2],
|
||||
}),
|
||||
).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: reviewerIds[0],
|
||||
});
|
||||
|
||||
await TrophyRepository.addApproval({
|
||||
pendingTrophyId,
|
||||
userId: reviewerIds[0],
|
||||
});
|
||||
expect(
|
||||
await TrophyRepository.addApproval({
|
||||
pendingTrophyId,
|
||||
userId: reviewerIds[1],
|
||||
}),
|
||||
).toBe(null);
|
||||
|
||||
expect(await trophyCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("trophy list tiers", () => {
|
||||
let authorId: number;
|
||||
let trophyId: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
const author = await UserFactory.create();
|
||||
authorId = author.id;
|
||||
|
||||
const trophy = await TrophyFactory.create({ name: "Tiered Trophy" });
|
||||
trophyId = trophy.id;
|
||||
});
|
||||
|
||||
test("an upcoming tournament without tier info does not hide the earned tier", async () => {
|
||||
await createTrophyTournament({ trophyId, tier: 3, startInDays: -21 });
|
||||
await createTrophyTournament({ trophyId, tier: null, startInDays: 10 });
|
||||
|
||||
expect(await findTrophyByName("Tiered Trophy")).toMatchObject({ tier: 3 });
|
||||
});
|
||||
|
||||
test("uses the most recent tier when multiple tournaments have one", async () => {
|
||||
await createTrophyTournament({ trophyId, tier: 5, startInDays: -30 });
|
||||
await createTrophyTournament({ trophyId, tier: 3, startInDays: -7 });
|
||||
|
||||
expect(await findTrophyByName("Tiered Trophy")).toMatchObject({ tier: 3 });
|
||||
});
|
||||
|
||||
test("has no tier when no linked tournament has tier info", async () => {
|
||||
await createTrophyTournament({ trophyId, tier: null, startInDays: 10 });
|
||||
|
||||
expect(await findTrophyByName("Tiered Trophy")).toMatchObject({
|
||||
tier: null,
|
||||
tentativeTier: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns the start time of the next upcoming tournament", async () => {
|
||||
await createTrophyTournament({ trophyId, tier: 3, startInDays: -21 });
|
||||
await createTrophyTournament({ trophyId, tier: null, startInDays: 20 });
|
||||
await createTrophyTournament({ trophyId, tier: null, startInDays: 10 });
|
||||
|
||||
const trophy = await findTrophyByName("Tiered Trophy");
|
||||
|
||||
const expected = dateToDatabaseTimestamp(daysFromNow(10));
|
||||
expect(
|
||||
Math.abs((trophy?.upcomingTournamentAt ?? 0) - expected),
|
||||
).toBeLessThan(10);
|
||||
});
|
||||
|
||||
test("sorts trophies with an upcoming tournament first within the same tier", async () => {
|
||||
await createTrophyTournament({ trophyId, tier: 3, startInDays: -21 });
|
||||
|
||||
const upcoming = await TrophyFactory.create({ name: "Upcoming Trophy" });
|
||||
await createTrophyTournament({
|
||||
trophyId: upcoming.id,
|
||||
tier: 3,
|
||||
startInDays: -14,
|
||||
});
|
||||
await createTrophyTournament({
|
||||
trophyId: upcoming.id,
|
||||
tier: null,
|
||||
startInDays: 10,
|
||||
});
|
||||
|
||||
const distant = await TrophyFactory.create({ name: "Distant Trophy" });
|
||||
await createTrophyTournament({
|
||||
trophyId: distant.id,
|
||||
tier: 3,
|
||||
startInDays: -7,
|
||||
});
|
||||
await createTrophyTournament({
|
||||
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",
|
||||
]);
|
||||
});
|
||||
|
||||
function createTrophyTournament({
|
||||
trophyId,
|
||||
tier,
|
||||
startInDays,
|
||||
}: {
|
||||
trophyId: number;
|
||||
tier: TournamentTierNumber | null;
|
||||
startInDays: number;
|
||||
}) {
|
||||
return TournamentFactory.create(
|
||||
{
|
||||
authorId,
|
||||
startTimes: [dateToDatabaseTimestamp(daysFromNow(startInDays))],
|
||||
trophyId,
|
||||
},
|
||||
tier ? { tier } : undefined,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
async function findTrophyByName(name: string) {
|
||||
return (await TrophyRepository.all()).find((row) => row.name === name);
|
||||
}
|
||||
|
||||
async function trophyCount() {
|
||||
const { count } = await db
|
||||
.selectFrom("Trophy")
|
||||
.select((eb) => eb.fn.countAll<number>().as("count"))
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
const daysFromNow = (days: number) =>
|
||||
new Date(Date.now() + days * 24 * 60 * 60 * 1000);
|
||||
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} data-testid="pending-trophy">
|
||||
<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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -20,6 +20,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<
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user