diff --git a/app/components/DotPagination.module.css b/app/components/DotPagination.module.css new file mode 100644 index 000000000..b07544875 --- /dev/null +++ b/app/components/DotPagination.module.css @@ -0,0 +1,27 @@ +.pagination { + display: flex; + flex-wrap: wrap; + gap: var(--s-3); + justify-content: center; + align-items: center; + max-width: 20rem; + margin: 0 auto; + margin-top: var(--s-2); +} + +.button { + background-color: var(--color-bg); + border-radius: 100%; + padding: var(--s-1); + height: 24px; + width: 24px; + border: var(--border-style); + font-size: var(--font-xs); + color: var(--color-text-high); +} + +.buttonActive { + color: var(--color-text-accent); + background-color: var(--color-bg-high); + border-color: var(--color-border-high); +} diff --git a/app/components/DotPagination.tsx b/app/components/DotPagination.tsx new file mode 100644 index 000000000..9cc4563a8 --- /dev/null +++ b/app/components/DotPagination.tsx @@ -0,0 +1,38 @@ +import { clsx } from "clsx"; +import { SendouButton } from "~/components/elements/Button"; +import styles from "./DotPagination.module.css"; + +export function DotPagination({ + pagesCount, + currentPage, + setPage, + ariaLabelPrefix, + "data-testid": testId, + className, +}: { + pagesCount: number; + currentPage: number; + setPage: (page: number) => void; + ariaLabelPrefix: string; + "data-testid"?: string; + className?: string; +}) { + return ( +
+ {Array.from({ length: pagesCount }, (_, i) => ( + setPage(i + 1)} + className={clsx(styles.button, { + [styles.buttonActive]: currentPage === i + 1, + })} + data-testid={testId} + > + {i + 1} + + ))} +
+ ); +} diff --git a/app/components/MobileNav.tsx b/app/components/MobileNav.tsx index 715efcd1f..3193b2891 100644 --- a/app/components/MobileNav.tsx +++ b/app/components/MobileNav.tsx @@ -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({ diff --git a/app/components/TierPill.module.css b/app/components/TierPill.module.css index 7ef542d23..d264d7565 100644 --- a/app/components/TierPill.module.css +++ b/app/components/TierPill.module.css @@ -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; + } } diff --git a/app/components/TierPill.tsx b/app/components/TierPill.tsx index 174493a45..7f10b5d8b 100644 --- a/app/components/TierPill.tsx +++ b/app/components/TierPill.tsx @@ -15,6 +15,8 @@ const TIER_STYLE_CLASS: Record = { 9: styles.tierC, }; +const POLISHED_TIERS = [1, 2, 3]; + export function TierPill({ tier, isTentative = false, @@ -29,7 +31,9 @@ export function TierPill({ return (
["results"][number], + { type: "organization" } +>; + +interface OrganizationSearchProps + extends Omit, "children" | "onChange"> { + name?: string; + label?: string; + bottomText?: string; + errorText?: string; + initialOrganizationId?: number; + onChange?: (organization: OrganizationSearchResult | null) => void; +} + +export const OrganizationSearch = React.forwardRef(function OrganizationSearch< + T extends object, +>( + { + name, + label, + bottomText, + errorText, + initialOrganizationId, + onChange, + ...rest + }: OrganizationSearchProps, + ref?: React.Ref, +) { + const initialOrganization = useInitialOrganization(initialOrganizationId); + + const search = useEntitySearch({ + buildUrl: (query) => `/search?q=${query}&type=organizations&limit=6`, + parseResults: (data, query) => + parseOrganizationResults(data, query, initialOrganization), + initialItem: initialOrganization, + initialSelectedId: initialOrganizationId, + onChange, + }); + + return ( + } + /> + ); +}); + +function parseOrganizationResults( + data: unknown, + query: string, + initialOrganization?: OrganizationSearchResult, +): OrganizationSearchResult[] | null { + const searchData = data as SearchLoaderData; + if (!searchData || searchData.query !== query) return null; + return searchData.results + .filter( + (result): result is OrganizationSearchResult => + result.type === "organization", + ) + .filter((org) => org.id !== initialOrganization?.id); +} + +function useInitialOrganization(initialOrganizationId?: number) { + const fetcher = useFetcher(); + + React.useEffect(() => { + if (!initialOrganizationId || fetcher.state !== "idle" || fetcher.data) { + return; + } + fetcher.load( + `/search?q=${initialOrganizationId}&type=organizations&limit=1`, + ); + }, [initialOrganizationId, fetcher]); + + return fetcher.data?.results.find( + (result): result is OrganizationSearchResult => + result.type === "organization", + ); +} + +function OrganizationItem({ item }: { item: OrganizationSearchResult }) { + return ( + + clsx(searchSelectStyles.item, { + [selectStyles.itemFocused]: isFocused, + [selectStyles.itemSelected]: isSelected, + }) + } + data-testid="organization-search-item" + > +
+ {item.name} +
+ /{item.slug} +
+
+
+ ); +} diff --git a/app/components/elements/SearchSelect.tsx b/app/components/elements/SearchSelect.tsx index b152ceee8..bbd1213c1 100644 --- a/app/components/elements/SearchSelect.tsx +++ b/app/components/elements/SearchSelect.tsx @@ -22,6 +22,10 @@ import selectStyles from "./Select.module.css"; import type { EntitySearch } from "./useEntitySearch"; const PLACEHOLDER_TEXTS = { + organizationSearch: { + placeholder: "common:forms.organizationSearch.placeholder", + noResults: "common:forms.organizationSearch.noResults", + }, teamSearch: { placeholder: "common:forms.teamSearch.placeholder", noResults: "common:forms.teamSearch.noResults", diff --git a/app/components/layout/AnythingAdder.tsx b/app/components/layout/AnythingAdder.tsx index 4a1861a7a..179bf182e 100644 --- a/app/components/layout/AnythingAdder.tsx +++ b/app/components/layout/AnythingAdder.tsx @@ -1,10 +1,12 @@ import { Plus } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useUser } from "~/features/auth/core/user"; +import { canAccessTrophies } from "~/features/trophies/trophies-utils"; import { CALENDAR_NEW_PAGE, lfgNewPostPage, NEW_TEAM_PAGE, + NEW_TROPHY_PAGE, navIconUrl, newArtPage, newAssociationsPage, @@ -97,6 +99,14 @@ export function AnythingAdder({ compact }: { compact?: boolean }) { imagePath: navIconUrl("plus"), href: plusSuggestionsNewPage(), }, + canAccessTrophies(user) + ? { + id: "trophy", + children: t("header.adder.trophy"), + imagePath: navIconUrl("trophies"), + href: NEW_TROPHY_PAGE, + } + : null, ].filter((item) => item !== null); return ( diff --git a/app/components/layout/TopNavMenus.tsx b/app/components/layout/TopNavMenus.tsx index 1e0c60101..dd322460b 100644 --- a/app/components/layout/TopNavMenus.tsx +++ b/app/components/layout/TopNavMenus.tsx @@ -7,6 +7,7 @@ import { Config } from "~/config"; import { NZAP_TEST_ID } from "~/db/seed/constants"; import { ADMIN_ID } from "~/features/admin/admin-constants"; import { useUser } from "~/features/auth/core/user"; +import { canAccessTrophies } from "~/features/trophies/trophies-utils"; import { impersonateUrl, navIconUrl, @@ -22,6 +23,10 @@ const DEV_IMPERSONATE_ITEMS = [ { name: "Logged out", icon: "log_in", action: STOP_IMPERSONATING_URL }, ] as const; +const DEV_LINK_ITEMS = [ + { name: "Components", icon: "settings", url: "/components" }, +] as const; + const NAV_CATEGORIES = [ { name: "play", @@ -61,7 +66,7 @@ const NAV_CATEGORIES = [ { name: "art", url: "art" }, { name: "articles", url: "a" }, { name: "vods", url: "vods" }, - { name: "badges", url: "badges" }, + { name: "trophies", url: "trophies" }, { name: "links", url: "links" }, { name: "plus", url: "plus/suggestions" }, ], @@ -125,6 +130,25 @@ function DevMenu() { ))} + {DEV_LINK_ITEMS.map((item) => ( + { + setIsOpen(false); + setIsPreviewSuppressed(true); + }} + > + + {item.name} + + ))}
{!isOpen && !isPreviewSuppressed ? ( @@ -149,6 +173,19 @@ function DevMenu() { ))} + {DEV_LINK_ITEMS.map((item) => ( + setIsPreviewSuppressed(true)} + > + + + ))} ) : null} @@ -167,9 +204,11 @@ function CategoryMenu({ const isStaff = user?.roles.includes("STAFF") ?? false; const showStaffOnly = isStaff || process.env.NODE_ENV === "development"; - const visibleItems = category.items.filter( - (item) => !("staffOnly" in item) || showStaffOnly, - ); + const visibleItems = category.items.filter((item) => { + if ("staffOnly" in item && !showStaffOnly) return false; + if (item.name === "trophies" && !canAccessTrophies(user)) return false; + return true; + }); return (
diff --git a/app/components/layout/nav-items.ts b/app/components/layout/nav-items.ts index 5f88b604d..fee7c175e 100644 --- a/app/components/layout/nav-items.ts +++ b/app/components/layout/nav-items.ts @@ -59,8 +59,8 @@ export const navItems = [ prefetch: false, }, { - name: "badges", - url: "badges", + name: "trophies", + url: "trophies", prefetch: false, }, { diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index 38af3c8e4..d5211e0d4 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -34,10 +34,17 @@ import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server" import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server"; import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server"; import { PRESET_COLORS } from "~/features/tier-list-maker/tier-list-maker-constants"; +import * as XRankPlacementRepository from "~/features/top-search/XRankPlacementRepository.server"; +import type { TournamentTierNumber } from "~/features/tournament/core/tiering"; import type { TournamentMapPickingStyle } from "~/features/tournament/tournament-constants"; import { clearAllTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server"; import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server"; import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server"; +import * as TrophyRepository from "~/features/trophies/TrophyRepository.server"; +import { + SUPPORTER_TROPHY_CODE, + XP_TROPHY_CODE_PREFIX, +} from "~/features/trophies/trophies-constants"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import { USER_REPORT_CATEGORIES } from "~/features/user-report/user-report-constants"; import * as VodRepository from "~/features/vods/VodRepository.server"; @@ -95,6 +102,7 @@ import { STAFF_TEST_ID, } from "./constants"; import placements from "./placements.json"; +import trophies from "./trophies.json"; const SENDOUQ_DEFAULT_MAPS: Record< ModeShort, @@ -254,6 +262,13 @@ const basicSeeds = (variation?: SeedVariation | null) => [ splatoonRotations, variation === "FINALIZED_BRACKET" ? finalizedBracket : undefined, variation === "AB_RR" ? abDivisionsTournament : undefined, + trophiesToDb, + trophyOwners, + trophyWinsTournament, + upcomingTrophyTournament, + pendingTrophiesToDb, + assignTrophyToTournament, + specialTrophies, ]; export async function seed(variation?: SeedVariation | null) { @@ -606,6 +621,11 @@ async function wipeDB() { "ModNote", "Friendship", "FriendRequest", + "PendingTrophyApproval", + "PendingTrophy", + "TrophyOwner", + "SpecialTrophyOwner", + "Trophy", "User", "PlusSuggestion", "PlusVote", @@ -1184,6 +1204,548 @@ async function badgeManagers() { } } +async function trophiesToDb() { + for (const [name, model] of Object.entries(trophies)) { + await db + .insertInto("Trophy") + .values({ + name, + model, + organizationId: 1, + creatorId: ADMIN_ID, + managerId: NZAP_TEST_ID, + }) + .execute(); + } +} + +async function trophyOwners() { + const trophyIds = (await db.selectFrom("Trophy").select("id").execute()).map( + (row) => row.id, + ); + + const tournamentIds = ( + await db.selectFrom("Tournament").select("id").execute() + ).map((row) => row.id); + + let userIds = ( + await db + .selectFrom("User") + .select("id") + .where("id", "not in", [NZAP_TEST_ID, ADMIN_ID]) + .execute() + ).map((row) => row.id); + + const randomTier = () => + faker.helpers.maybe(() => faker.number.int({ min: 1, max: 9 }), { + probability: 0.85, + }) ?? null; + + const usedCombinations = new Set(); + const insertOwner = async ( + trophyId: number, + userId: number, + tournamentId: number, + ) => { + const key = `${trophyId}-${userId}-${tournamentId}`; + if (usedCombinations.has(key)) return; + usedCombinations.add(key); + await db + .insertInto("TrophyOwner") + .values({ + trophyId, + userId, + tournamentId, + tier: randomTier(), + }) + .execute(); + }; + + for (const trophyId of trophyIds) { + userIds = faker.helpers.shuffle(userIds); + const ownerCount = faker.number.int({ min: 1, max: 8 }); + + for (let i = 0; i < ownerCount; i++) { + const userId = userIds.shift()!; + const copies = faker.number.int({ min: 1, max: 3 }); + const shuffledTournaments = faker.helpers.shuffle([...tournamentIds]); + + for (let j = 0; j < copies && j < shuffledTournaments.length; j++) { + await insertOwner(trophyId, userId, shuffledTournaments[j]); + } + + userIds.push(userId); + } + } + + for (const trophyId of trophyIds) { + const shuffledTournaments = faker.helpers.shuffle([...tournamentIds]); + const copies = faker.number.int({ min: 1, max: 3 }); + + for (let i = 0; i < copies && i < shuffledTournaments.length; i++) { + await insertOwner(trophyId, ADMIN_ID, shuffledTournaments[i]); + } + } +} + +const TROPHY_TOURNAMENT_ID = 9; +const TROPHY_EVENT_ID = 209; +const TROPHY_TEAM_ID_OFFSET = 800; +// bracket rows use explicit non-positive ids so brackets started at runtime +// (and tests referencing their seeded match ids) keep autoincrementing from 1 +const TROPHY_STAGE_ID = 0; +const TROPHY_GROUP_ID = 0; +const TROPHY_ROUND_ID_OFFSET = -3; +const TROPHY_MATCH_ID_OFFSET = -7; +const TROPHY_GAME_RESULT_ID_OFFSET = -14; + +async function trophyWinsTournament() { + await insertTournamentWithId({ + id: TROPHY_TOURNAMENT_ID, + mapPickingStyle: "AUTO_ALL", + settings: JSON.stringify({ + isRanked: true, + bracketProgression: [ + { + type: "single_elimination", + name: "Bracket", + requiresCheckIn: false, + settings: { thirdPlaceMatch: false }, + }, + ], + }), + isFinalized: 1, + tier: 3, + }); + + await insertCalendarEventWithId({ + id: TROPHY_EVENT_ID, + name: "Trophy Cup", + description: "Finished tournament with a trophy prize", + discordInviteCode: "test", + bracketUrl: "https://example.com", + authorId: ADMIN_ID, + tournamentId: TROPHY_TOURNAMENT_ID, + trophyId: 1, + }); + + await db + .insertInto("CalendarEventDate") + .values({ + eventId: TROPHY_EVENT_ID, + startsAt: dateToDatabaseTimestamp( + new Date(Date.now() - 1000 * 60 * 60 * 24 * 21), + ), + }) + .execute(); + + const userIds = await userIdsInAscendingOrderById(); + const teamNames = [ + "Splat Society", + "Ink Theory", + "Booyah Brigade", + "Chargers Anonymous", + "Squid Parts", + "Woomy Council", + "Roller Coalition", + "Last Splash", + ]; + const teamMembers = new Map(); + + for (let i = 0; i < 8; i++) { + const teamId = TROPHY_TEAM_ID_OFFSET + i + 1; + + await insertTournamentTeamWithId({ + id: teamId, + name: teamNames[i], + createdAt: dateToDatabaseTimestamp(new Date()), + tournamentId: TROPHY_TOURNAMENT_ID, + inviteCode: shortNanoid(), + seed: i + 1, + }); + + await db + .insertInto("TournamentTeamCheckIn") + .values({ + tournamentTeamId: teamId, + checkedInAt: dateToDatabaseTimestamp(new Date()), + }) + .execute(); + + const members: number[] = []; + for (let j = 0; j < 4; j++) { + const userId = userIds.shift()!; + members.push(userId); + + await db + .insertInto("TournamentTeamMember") + .values({ + tournamentTeamId: teamId, + userId, + createdAt: dateToDatabaseTimestamp(new Date()), + role: j === 0 ? "OWNER" : "REGULAR", + }) + .execute(); + } + teamMembers.set(teamId, members); + } + + // Bracket structure + const stageId = TROPHY_STAGE_ID; + await insertTournamentStageWithId({ + id: stageId, + tournamentId: TROPHY_TOURNAMENT_ID, + name: "Bracket", + number: 1, + type: "single_elimination", + settings: JSON.stringify({ thirdPlaceMatch: false }), + }); + + const groupId = TROPHY_GROUP_ID; + await insertTournamentGroupWithId({ id: groupId, stageId, number: 1 }); + + const roundMaps = JSON.stringify({ count: 3, type: "BEST_OF" }); + + const roundIds: number[] = []; + for (let r = 1; r <= 3; r++) { + const roundId = TROPHY_ROUND_ID_OFFSET + r; + await insertTournamentRoundWithId({ + id: roundId, + stageId, + groupId, + number: r, + maps: roundMaps, + }); + roundIds.push(roundId); + } + + const t = (seed: number) => TROPHY_TEAM_ID_OFFSET + seed; + + // SE 8-team bracket: standard seeding, higher seed always wins + const matches = [ + { round: 0, number: 1, team1: t(1), team2: t(8), winner: t(1) }, + { round: 0, number: 2, team1: t(4), team2: t(5), winner: t(4) }, + { round: 0, number: 3, team1: t(2), team2: t(7), winner: t(2) }, + { round: 0, number: 4, team1: t(3), team2: t(6), winner: t(3) }, + { round: 1, number: 1, team1: t(1), team2: t(4), winner: t(1) }, + { round: 1, number: 2, team1: t(2), team2: t(3), winner: t(2) }, + { round: 2, number: 1, team1: t(1), team2: t(2), winner: t(1) }, + ]; + + const weaponPools = new Map(); + const weaponPoolFor = (userId: number) => { + const existing = weaponPools.get(userId); + if (existing) return existing; + + const pool = faker.helpers.arrayElements(canonicalMainWeaponIds, { + min: 1, + max: 3, + }); + weaponPools.set(userId, pool); + return pool; + }; + + let gameResultId = TROPHY_GAME_RESULT_ID_OFFSET; + for (const [matchIndex, m] of matches.entries()) { + const matchId = TROPHY_MATCH_ID_OFFSET + matchIndex + 1; + await insertTournamentMatchWithId({ + id: matchId, + stageId, + groupId, + roundId: roundIds[m.round], + number: m.number, + opponentOne: JSON.stringify({ + id: m.team1, + score: m.winner === m.team1 ? 2 : 0, + }), + opponentTwo: JSON.stringify({ + id: m.team2, + score: m.winner === m.team2 ? 2 : 0, + }), + winnerSide: m.winner === m.team1 ? "opponent1" : "opponent2", + }); + + // 2 game results (2-0 sweep) with weapons reported for every player + for (let g = 1; g <= 2; g++) { + gameResultId += 1; + await insertTournamentMatchGameResultWithId({ + id: gameResultId, + matchId, + mode: "SZ", + number: g, + reporterId: ADMIN_ID, + source: "DEFAULT", + stageId: 1, + winnerTeamId: m.winner, + }); + + for (const teamId of [m.team1, m.team2]) { + for (const userId of teamMembers.get(teamId)!) { + await db + .insertInto("ReportedWeapon") + .values({ + tournamentMatchId: matchId, + mapIndex: g - 1, + weaponSplId: faker.helpers.arrayElement(weaponPoolFor(userId)), + userId, + createdAt: dateToDatabaseTimestamp(new Date()), + }) + .execute(); + } + } + } + } + + const placements = [ + { teamSeed: 1, placement: 1, setResults: ["W", "W", "W"] }, + { teamSeed: 2, placement: 2, setResults: ["W", "W", "L"] }, + { teamSeed: 3, placement: 3, setResults: ["W", "L"] }, + { teamSeed: 4, placement: 3, setResults: ["W", "L"] }, + { teamSeed: 5, placement: 5, setResults: ["L"] }, + { teamSeed: 6, placement: 5, setResults: ["L"] }, + { teamSeed: 7, placement: 5, setResults: ["L"] }, + { teamSeed: 8, placement: 5, setResults: ["L"] }, + ]; + + for (const p of placements) { + const teamId = t(p.teamSeed); + + for (const [memberIndex, userId] of teamMembers.get(teamId)!.entries()) { + const setResults = p.setResults.map((result) => + memberIndex > 0 && faker.number.float(1) > 0.75 ? null : result, + ); + const spDiff = + Math.round( + (p.placement === 1 + ? faker.number.float({ min: 80, max: 220 }) + : faker.number.float({ min: -120, max: 60 })) * 10, + ) / 10; + + await db + .insertInto("TournamentResult") + .values({ + tournamentId: TROPHY_TOURNAMENT_ID, + tournamentTeamId: teamId, + userId, + placement: p.placement, + participantCount: 8, + setResults: JSON.stringify(setResults), + spDiff, + }) + .execute(); + } + } + + for (const userId of teamMembers.get(t(1))!) { + await db + .insertInto("TrophyOwner") + .values({ + trophyId: 1, + userId, + tournamentId: TROPHY_TOURNAMENT_ID, + tier: 3, + }) + .execute(); + } +} + +const UPCOMING_TROPHY_TOURNAMENT_ID = 10; +const UPCOMING_TROPHY_EVENT_ID = 210; + +async function upcomingTrophyTournament() { + await insertTournamentWithId({ + id: UPCOMING_TROPHY_TOURNAMENT_ID, + mapPickingStyle: "AUTO_ALL", + settings: JSON.stringify({ + isRanked: true, + bracketProgression: [ + { + type: "single_elimination", + name: "Bracket", + requiresCheckIn: false, + settings: { thirdPlaceMatch: false }, + }, + ], + }), + }); + + await insertCalendarEventWithId({ + id: UPCOMING_TROPHY_EVENT_ID, + name: "Trophy Cup 2", + description: "Upcoming tournament with a trophy prize", + discordInviteCode: "test", + bracketUrl: "https://example.com", + authorId: ADMIN_ID, + tournamentId: UPCOMING_TROPHY_TOURNAMENT_ID, + trophyId: 1, + }); + + await db + .insertInto("CalendarEventDate") + .values({ + eventId: UPCOMING_TROPHY_EVENT_ID, + startsAt: dateToDatabaseTimestamp( + new Date(Date.now() + 1000 * 60 * 60 * 24 * 10), + ), + }) + .execute(); +} + +async function pendingTrophiesToDb() { + const userIds = ( + await db + .selectFrom("User") + .select("id") + .where("id", "!=", ADMIN_ID) + .orderBy(sql`random()`) + .limit(10) + .execute() + ).map((row) => row.id); + + const orgIds = ( + await db + .selectFrom("TournamentOrganization") + .select("id") + .limit(5) + .execute() + ).map((row) => row.id); + + const trophyEntries = Object.entries(trophies); + + const insertPending = (values: { + name: string; + model: string; + createdAt: number; + declineReason?: string | null; + declinedAt?: number | null; + declinedByUserId?: number | null; + }) => + db + .insertInto("PendingTrophy") + .values({ + name: values.name, + model: values.model, + description: faker.lorem.sentence(), + organizationId: faker.helpers.arrayElement(orgIds), + submitterUserId: faker.helpers.arrayElement(userIds), + createdAt: values.createdAt, + declineReason: values.declineReason ?? null, + declinedAt: values.declinedAt ?? null, + declinedByUserId: values.declinedByUserId ?? null, + }) + .returning("id") + .executeTakeFirstOrThrow(); + + const insertApproval = (values: { + pendingTrophyId: number; + userId: number; + createdAt: number; + }) => db.insertInto("PendingTrophyApproval").values(values).execute(); + + const now = Math.floor(Date.now() / 1000); + + // 5 pending + for (let i = 0; i < 5; i++) { + const [trophyName, model] = trophyEntries[i % trophyEntries.length]; + await insertPending({ + name: `Pending ${trophyName} ${i + 1}`, + model, + createdAt: now - i * 3600, + }); + } + + // 2 with one approval + for (let i = 0; i < 2; i++) { + const [trophyName, model] = trophyEntries[(i + 5) % trophyEntries.length]; + const pending = await insertPending({ + name: `Partial ${trophyName} ${i + 1}`, + model, + createdAt: now - (i + 5) * 3600, + }); + + await insertApproval({ + pendingTrophyId: pending.id, + userId: ADMIN_ID, + createdAt: now - i * 1800, + }); + } + + // 3 fully approved + for (let i = 0; i < 3; i++) { + const [trophyName, model] = trophyEntries[(i + 7) % trophyEntries.length]; + const pending = await insertPending({ + name: `Accepted ${trophyName} ${i + 1}`, + model, + createdAt: now - (i + 7) * 3600, + }); + + await insertApproval({ + pendingTrophyId: pending.id, + userId: ADMIN_ID, + createdAt: now - (i + 1) * 1800, + }); + await insertApproval({ + pendingTrophyId: pending.id, + userId: NZAP_TEST_ID, + createdAt: now - i * 1800, + }); + } + + // 3 declined + for (let i = 0; i < 3; i++) { + const [trophyName, model] = trophyEntries[(i + 10) % trophyEntries.length]; + await insertPending({ + name: `Declined ${trophyName} ${i + 1}`, + model, + createdAt: now - (i + 10) * 3600, + declineReason: faker.lorem.sentence(), + declinedAt: now - i * 1800, + declinedByUserId: ADMIN_ID, + }); + } +} + +async function assignTrophyToTournament() { + const PICNIC_EVENT_ID = 201; + await db + .deleteFrom("CalendarEventBadge") + .where("eventId", "=", PICNIC_EVENT_ID) + .execute(); + await db + .updateTable("CalendarEvent") + .set({ trophyId: 1 }) + .where("id", "=", PICNIC_EVENT_ID) + .execute(); +} + +async function specialTrophies() { + const models = Object.values(trophies); + + const specialTrophyValues = [ + { + name: "Supporter", + model: models[0 % models.length], + code: SUPPORTER_TROPHY_CODE, + }, + { + name: "3000 X Power", + model: models[1 % models.length], + code: `${XP_TROPHY_CODE_PREFIX}3000`, + }, + { + name: "2600 X Power", + model: models[2 % models.length], + code: `${XP_TROPHY_CODE_PREFIX}2600`, + }, + ]; + + for (const trophy of specialTrophyValues) { + await db.insertInto("Trophy").values(trophy).execute(); + } + + await TrophyRepository.syncSpecialTrophies(); +} + async function patrons() { const userIds = ( await db @@ -2184,8 +2746,8 @@ async function realVideoCast() { } // some copy+paste from placements script -function xRankPlacements() { - return db.transaction().execute(async (trx) => { +async function xRankPlacements() { + await db.transaction().execute(async (trx) => { for (const [i, placement] of placements.entries()) { const userId = () => { // admin @@ -2219,6 +2781,8 @@ function xRankPlacements() { .execute(); } }); + + await XRankPlacementRepository.refreshAllPeakXp(); } const addUnvalidatedUserSubmittedImage = (url: string, authorId: number) => @@ -3381,10 +3945,11 @@ function insertTournamentWithId(values: { mapPickingStyle: TournamentMapPickingStyle; settings: string; isFinalized?: DBBoolean; + tier?: TournamentTierNumber | null; }) { return sql` - insert into "Tournament" ("id", "mapPickingStyle", "settings", "isFinalized") - values (${values.id}, ${values.mapPickingStyle}, ${values.settings}, ${values.isFinalized ?? 0}) + insert into "Tournament" ("id", "mapPickingStyle", "settings", "isFinalized", "tier") + values (${values.id}, ${values.mapPickingStyle}, ${values.settings}, ${values.isFinalized ?? 0}, ${values.tier ?? null}) `.execute(db); } @@ -3399,10 +3964,11 @@ function insertCalendarEventWithId(values: { organizationId?: number | null; avatarImgId?: number | null; tags?: string | null; + trophyId?: number | null; }) { return sql` - insert into "CalendarEvent" ("id", "name", "description", "discordInviteCode", "bracketUrl", "authorId", "tournamentId", "organizationId", "avatarImgId", "tags") - values (${values.id}, ${values.name}, ${values.description}, ${values.discordInviteCode}, ${values.bracketUrl}, ${values.authorId}, ${values.tournamentId ?? null}, ${values.organizationId ?? null}, ${values.avatarImgId ?? null}, ${values.tags ?? null}) + insert into "CalendarEvent" ("id", "name", "description", "discordInviteCode", "bracketUrl", "authorId", "tournamentId", "organizationId", "avatarImgId", "tags", "trophyId") + values (${values.id}, ${values.name}, ${values.description}, ${values.discordInviteCode}, ${values.bracketUrl}, ${values.authorId}, ${values.tournamentId ?? null}, ${values.organizationId ?? null}, ${values.avatarImgId ?? null}, ${values.tags ?? null}, ${values.trophyId ?? null}) `.execute(db); } @@ -3419,3 +3985,73 @@ function insertTournamentTeamWithId(values: { values (${values.id}, ${values.name}, ${values.createdAt}, ${values.tournamentId}, ${values.inviteCode}, ${values.seed}) `.execute(db); } + +function insertTournamentStageWithId(values: { + id: number; + tournamentId: number; + name: string; + number: number; + type: string; + settings: string; +}) { + return sql` + insert into "TournamentStage" ("id", "tournamentId", "name", "number", "type", "settings") + values (${values.id}, ${values.tournamentId}, ${values.name}, ${values.number}, ${values.type}, ${values.settings}) + `.execute(db); +} + +function insertTournamentGroupWithId(values: { + id: number; + stageId: number; + number: number; +}) { + return sql` + insert into "TournamentGroup" ("id", "stageId", "number") + values (${values.id}, ${values.stageId}, ${values.number}) + `.execute(db); +} + +function insertTournamentRoundWithId(values: { + id: number; + stageId: number; + groupId: number; + number: number; + maps: string; +}) { + return sql` + insert into "TournamentRound" ("id", "stageId", "groupId", "number", "maps") + values (${values.id}, ${values.stageId}, ${values.groupId}, ${values.number}, ${values.maps}) + `.execute(db); +} + +function insertTournamentMatchWithId(values: { + id: number; + stageId: number; + groupId: number; + roundId: number; + number: number; + opponentOne: string; + opponentTwo: string; + winnerSide: "opponent1" | "opponent2"; +}) { + return sql` + insert into "TournamentMatch" ("id", "stageId", "groupId", "roundId", "number", "opponentOne", "opponentTwo", "winnerSide") + values (${values.id}, ${values.stageId}, ${values.groupId}, ${values.roundId}, ${values.number}, ${values.opponentOne}, ${values.opponentTwo}, ${values.winnerSide}) + `.execute(db); +} + +function insertTournamentMatchGameResultWithId(values: { + id: number; + matchId: number; + mode: string; + number: number; + reporterId: number; + source: string; + stageId: number; + winnerTeamId: number; +}) { + return sql` + insert into "TournamentMatchGameResult" ("id", "matchId", "mode", "number", "reporterId", "source", "stageId", "winnerTeamId") + values (${values.id}, ${values.matchId}, ${values.mode}, ${values.number}, ${values.reporterId}, ${values.source}, ${values.stageId}, ${values.winnerTeamId}) + `.execute(db); +} diff --git a/app/db/seed/trophies.json b/app/db/seed/trophies.json new file mode 100644 index 000000000..76272c258 --- /dev/null +++ b/app/db/seed/trophies.json @@ -0,0 +1,5 @@ +{ + "Wellstring Wednesday": "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", + "Gun With a Really Long Name For No Reason": "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", + "Chris P. Bacon": "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" +} diff --git a/app/db/tables.ts b/app/db/tables.ts index 7a1111bd0..2b9bb82c3 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -165,6 +165,51 @@ export interface BadgeOwner { count: number; } +export interface Trophy { + id: GeneratedAlways; + name: string; + model: string; + /** Identifies special trophies, null for regular trophies. */ + code: Generated; + organizationId: number | null; + creatorId: number | null; + managerId: number | null; +} + +export interface TrophyOwner { + trophyId: number; + userId: number; + tournamentId: number; + tier: number | null; +} + +export interface SpecialTrophyOwner { + trophyId: number; + userId: number; + createdAt: number; +} + +export interface PendingTrophy { + id: GeneratedAlways; + name: string; + model: string; + description: string; + organizationId: number | null; + submitterUserId: number; + createdAt: number; + declineReason: string | null; + declinedAt: number | null; + declinedByUserId: number | null; + targetTrophyId: number | null; + managerId: number | null; +} + +export interface PendingTrophyApproval { + pendingTrophyId: number; + userId: number; + createdAt: number; +} + export interface Build { clothesGearSplId: number | null; description: string | null; @@ -222,6 +267,7 @@ export interface CalendarEvent { tournamentId: number | null; organizationId: number | null; avatarImgId: number | null; + trophyId: number | null; } export interface CalendarEventBadge { @@ -807,6 +853,8 @@ export interface User { discordUniqueName: string | null; /** User's favorite badges they want to show on the front page of the badge display. Index = 0 big badge. */ favoriteBadgeIds: JSONColumnTypeNullable; + favoriteTrophyIds: JSONColumnTypeNullable; + hiddenTrophyIds: JSONColumnTypeNullable; id: GeneratedAlways; inGameName: string | null; isArtist: Generated; @@ -1245,6 +1293,11 @@ export interface DB { TournamentOrganizationBannedUser: TournamentOrganizationBannedUser; TournamentStreamer: TournamentStreamer; TournamentMatchVod: TournamentMatchVod; + Trophy: Trophy; + TrophyOwner: TrophyOwner; + SpecialTrophyOwner: SpecialTrophyOwner; + PendingTrophy: PendingTrophy; + PendingTrophyApproval: PendingTrophyApproval; TrustRelationship: TrustRelationship; Friendship: Friendship; FriendRequest: FriendRequest; diff --git a/app/features/admin/AdminRepository.server.ts b/app/features/admin/AdminRepository.server.ts index 9104a94f6..29718712b 100644 --- a/app/features/admin/AdminRepository.server.ts +++ b/app/features/admin/AdminRepository.server.ts @@ -5,6 +5,7 @@ import { actorId } from "~/features/auth/core/user.server"; import * as BadgeRepository from "~/features/badges/BadgeRepository.server"; import * as BuildRepository from "~/features/builds/BuildRepository.server"; import * as XRankPlacementRepository from "~/features/top-search/XRankPlacementRepository.server"; +import * as TrophyRepository from "~/features/trophies/TrophyRepository.server"; import { dateToDatabaseTimestamp } from "~/utils/dates"; import invariant from "~/utils/invariant"; @@ -283,18 +284,19 @@ export async function linkUserAndPlayer({ .execute(); await BadgeRepository.syncXPBadges(); + await TrophyRepository.syncSpecialTrophies(); await BuildRepository.recalculateAllSortValues(userId); await XRankPlacementRepository.refreshTenStarWeapons(userId); } -export function forcePatron(args: { +export async function forcePatron(args: { id: number; patronTier: Tables["User"]["patronTier"]; patronStartedAt: Date; patronExpiresAt: Date; }) { - return db + await db .updateTable("User") .set({ patronTier: args.patronTier, @@ -303,6 +305,8 @@ export function forcePatron(args: { }) .where("User.id", "=", args.id) .execute(); + + await TrophyRepository.syncSpecialTrophies(); } export async function findAllBannedUsers() { diff --git a/app/features/admin/admin-constants.ts b/app/features/admin/admin-constants.ts index 3a72e5175..a8526ca30 100644 --- a/app/features/admin/admin-constants.ts +++ b/app/features/admin/admin-constants.ts @@ -7,6 +7,8 @@ export const ADMIN_ID = process.env.NODE_ENV === "test" ? 1 : 274; export const STAFF_IDS = [11329, 9719, 9342, 20774, 23094]; // hfcRed export const DEV_IDS = [27883]; +// hfcRed Dreamy Cafy +export const QA_IDS: number[] = [27883, 38781, 10654]; export const STAFF_DISCORD_IDS = [ "138757634500067328", diff --git a/app/features/api-private/routes/seed.ts b/app/features/api-private/routes/seed.ts index 7a3cdd5e3..5f2699675 100644 --- a/app/features/api-private/routes/seed.ts +++ b/app/features/api-private/routes/seed.ts @@ -63,6 +63,8 @@ export const action: ActionFunction = async ({ request }) => { }; const REG_OPEN_TOURNAMENT_IDS = [1, 3]; +const FINISHED_IN_THE_PAST_EVENT_IDS = [209]; +const UPCOMING_EVENT_IDS = [210]; const SEED_REFERENCE_TIMESTAMP = 1767440151; @@ -76,6 +78,9 @@ async function adjustSeedDatesToCurrent(variation: SeedVariation) { 1000, ); const now = Math.floor(Date.now() / 1000); + const tenDaysFromNow = Math.floor( + (Date.now() + 1000 * 60 * 60 * 24 * 10) / 1000, + ); const tournamentEventIds = await db .selectFrom("CalendarEvent") @@ -85,6 +90,17 @@ async function adjustSeedDatesToCurrent(variation: SeedVariation) { .execute(); for (const { id, tournamentId } of tournamentEventIds) { + if (FINISHED_IN_THE_PAST_EVENT_IDS.includes(id)) continue; + + if (UPCOMING_EVENT_IDS.includes(id)) { + await db + .updateTable("CalendarEventDate") + .set({ startsAt: tenDaysFromNow }) + .where("eventId", "=", id) + .execute(); + continue; + } + const isRegOpen = variation === "REG_OPEN" && REG_OPEN_TOURNAMENT_IDS.includes(tournamentId); diff --git a/app/features/badges/components/BadgeDisplay.module.css b/app/features/badges/components/BadgeDisplay.module.css index d9e3fd3dc..733c40b7c 100644 --- a/app/features/badges/components/BadgeDisplay.module.css +++ b/app/features/badges/components/BadgeDisplay.module.css @@ -46,29 +46,5 @@ } .pagination { - display: flex; - flex-wrap: wrap; - gap: var(--s-3); - justify-content: center; - align-items: center; - max-width: 20rem; - margin: 0 auto; margin-block-start: var(--s-2); } - -.paginationButton { - background-color: var(--color-bg); - border-radius: 100%; - padding: var(--s-1); - height: 24px; - width: 24px; - border: var(--border-style); - font-size: var(--font-xs); - color: var(--color-text-high); -} - -.paginationButtonActive { - color: var(--color-text-accent); - background-color: var(--color-bg-high); - border-color: var(--color-border-high); -} diff --git a/app/features/badges/components/BadgeDisplay.tsx b/app/features/badges/components/BadgeDisplay.tsx index 3d9b5cc7c..73852d11b 100644 --- a/app/features/badges/components/BadgeDisplay.tsx +++ b/app/features/badges/components/BadgeDisplay.tsx @@ -3,6 +3,7 @@ import { Trash } from "lucide-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { Badge } from "~/components/Badge"; +import { DotPagination } from "~/components/DotPagination"; import { SendouButton } from "~/components/elements/Button"; import type { Tables } from "~/db/tables"; import { BADGE } from "~/features/badges/badges-constants"; @@ -108,43 +109,15 @@ export function BadgeDisplay({
) : null} {!everythingVisible ? ( - ) : null} ); } - -interface BadgePaginationProps { - pagesCount: number; - currentPage: number; - setPage: (page: number) => void; -} - -function BadgePagination({ - pagesCount, - currentPage, - setPage, -}: BadgePaginationProps) { - return ( -
- {Array.from({ length: pagesCount }, (_, i) => ( - setPage(i + 1)} - className={clsx(styles.paginationButton, { - [styles.paginationButtonActive]: currentPage === i + 1, - })} - data-testid="badge-pagination-button" - > - {i + 1} - - ))} -
- ); -} diff --git a/app/features/badges/routes/badges.$id.tsx b/app/features/badges/routes/badges.$id.tsx index 90cacf51d..13afd4cdd 100644 --- a/app/features/badges/routes/badges.$id.tsx +++ b/app/features/badges/routes/badges.$id.tsx @@ -90,7 +90,7 @@ export default function BadgeDetailsPage() { Edit ) : null} -
+
    {data.badge.owners.map((owner) => (
  • diff --git a/app/features/calendar/CalendarRepository.server.ts b/app/features/calendar/CalendarRepository.server.ts index a8734df17..eb0be7211 100644 --- a/app/features/calendar/CalendarRepository.server.ts +++ b/app/features/calendar/CalendarRepository.server.ts @@ -82,6 +82,15 @@ const withBadgePrizes = (eb: ExpressionBuilder) => { ).as("badgePrizes"); }; +const withTrophy = (eb: ExpressionBuilder) => { + return jsonObjectFrom( + eb + .selectFrom("Trophy") + .select(["Trophy.id", "Trophy.name", "Trophy.model"]) + .whereRef("Trophy.id", "=", "CalendarEvent.trophyId"), + ).as("trophy"); +}; + function tournamentOrganization(organizationId: Expression) { return jsonObjectFrom( db @@ -200,6 +209,12 @@ function findAllBetweenTwoTimestampsQuery({ ) .orderBy("Badge.id", "asc"), ).as("badges"), + jsonObjectFrom( + eb + .selectFrom("Trophy") + .select(["Trophy.model"]) + .whereRef("Trophy.id", "=", "CalendarEvent.trophyId"), + ).as("trophy"), ]) .where("CalendarEvent.hidden", "=", 0) .where( @@ -256,6 +271,7 @@ function findAllBetweenTwoTimestampsMapped( ? modesIncluded(row.mapPickingStyle, row.toSetMapPool) : null, badges: row.badges, + trophy: row.trophy, logoUrl: row.logoUrl, startsAt: row.normalizedStartsAt, isRanked: row.tournamentSettings @@ -289,10 +305,12 @@ export async function findById( includeMapPool = false, includeTieBreakerMapPool = false, includeBadgePrizes = false, + includeTrophy = false, }: { includeMapPool?: boolean; includeTieBreakerMapPool?: boolean; includeBadgePrizes?: boolean; + includeTrophy?: boolean; } = {}, ) { const [firstRow, ...rest] = await db @@ -300,6 +318,7 @@ export async function findById( .$if(includeMapPool, (qb) => qb.select(withMapPool)) .$if(includeTieBreakerMapPool, (qb) => qb.select(withTieBreakerMapPool)) .$if(includeBadgePrizes, (qb) => qb.select(withBadgePrizes)) + .$if(includeTrophy, (qb) => qb.select(withTrophy)) .innerJoin( "CalendarEventDate", "CalendarEvent.id", @@ -407,6 +426,7 @@ type CreateArgs = Pick< > & { startTimes: Array; badges: Array; + trophyId?: Tables["CalendarEvent"]["trophyId"]; mapPoolMaps?: Array>; isFullTournament: boolean; mapPickingStyle: Tables["Tournament"]["mapPickingStyle"]; @@ -521,6 +541,7 @@ export async function insert(args: CreateArgs) { organizationId: args.organizationId, hidden: args.parentTournamentId || args.isTest || args.isDraft ? 1 : 0, tournamentId, + trophyId: args.trophyId ?? null, }) .returning("id") .executeTakeFirstOrThrow(); @@ -586,6 +607,7 @@ export async function update(args: UpdateArgs) { bracketUrl: args.bracketUrl, avatarImgId: args.avatarImgId ?? avatarImgId, organizationId: args.organizationId, + trophyId: args.trophyId ?? null, }) .where("id", "=", args.eventId) .returning("tournamentId") diff --git a/app/features/calendar/actions/calendar.new.server.ts b/app/features/calendar/actions/calendar.new.server.ts index fae255ed6..8f0a94c1b 100644 --- a/app/features/calendar/actions/calendar.new.server.ts +++ b/app/features/calendar/actions/calendar.new.server.ts @@ -10,6 +10,8 @@ import { clearTournamentDataCache, tournamentFromDB, } from "~/features/tournament-bracket/core/Tournament.server"; +import * as TrophyRepository from "~/features/trophies/TrophyRepository.server"; +import { canAccessTrophies } from "~/features/trophies/trophies-utils"; import { parseFormDataWithImages } from "~/form/parse.server"; import { rankedModesShort } from "~/modules/in-game-lists/modes"; import { requireRole } from "~/modules/permissions/guards.server"; @@ -60,6 +62,20 @@ export const action: ActionFunction = async ({ request }) => { ); } + if (data.trophyId) { + if (!canAccessTrophies(user)) { + errorToast("Trophies are not released yet"); + } + + const trophyOrganizationId = await TrophyRepository.findOrganizationIdById( + data.trophyId, + ); + if (trophyOrganizationId !== organizationId) { + errorToast("Trophy does not belong to the selected organization"); + } + data.badges = []; + } + const managedBadges = await BadgeRepository.findManagedByUserId(user.id); const dates = @@ -86,6 +102,7 @@ export const action: ActionFunction = async ({ request }) => { badges: data.badges.filter((badge) => managedBadges.some((mb) => mb.id === badge), ), + trophyId: data.trophyId ?? null, // resolved by parseFormDataWithImages from the `image()` field avatarImgId: data.avatarImgId ?? undefined, toToolsEnabled: Number(data.toToolsEnabled), diff --git a/app/features/calendar/calendar-new-schemas.ts b/app/features/calendar/calendar-new-schemas.ts index 4b332e0c5..21b3699be 100644 --- a/app/features/calendar/calendar-new-schemas.ts +++ b/app/features/calendar/calendar-new-schemas.ts @@ -19,6 +19,7 @@ import { toggle, } from "~/form/fields"; import { rankedModesShort } from "~/modules/in-game-lists/modes"; +import { id } from "~/utils/zod"; import { CALENDAR_EVENT, REG_CLOSES_AT_OPTIONS } from "./calendar-constants"; import { bracketProgressionSchema } from "./calendar-schemas"; import { calendarEventMaxDate, calendarEventMinDate } from "./calendar-utils"; @@ -82,6 +83,7 @@ export const calendarNewBaseSchema = z.object({ })), }), badges: badges({ label: "labels.badges", maxCount: 50 }), + trophyId: customField({ initialValue: null }, id.nullish()), avatarImgId: image({ label: "labels.logo", bottomText: "bottomTexts.avatarValidation", @@ -212,6 +214,14 @@ export function calendarNewSyncRefine( } } + if (data.trophyId && data.badges.length > 0) { + ctx.addIssue({ + path: ["badges"], + code: z.ZodIssueCode.custom, + message: "forms:errors.trophyWithBadges", + }); + } + if ( data.toToolsEnabled && data.minMembersPerTeam === "4" && diff --git a/app/features/calendar/calendar-new.module.css b/app/features/calendar/calendar-new.module.css new file mode 100644 index 000000000..336dfe9b7 --- /dev/null +++ b/app/features/calendar/calendar-new.module.css @@ -0,0 +1,5 @@ +.trophyPreview { + background-color: var(--color-bg-high); + border: var(--border-style); + border-radius: var(--radius-field); +} diff --git a/app/features/calendar/calendar-types.ts b/app/features/calendar/calendar-types.ts index 4783a4e86..152e7b7d4 100644 --- a/app/features/calendar/calendar-types.ts +++ b/app/features/calendar/calendar-types.ts @@ -40,6 +40,7 @@ export interface CalendarEvent extends CommonEvent { badges: Array< Pick > | null; + trophy: Pick | null; } export interface ShowcaseCalendarEvent extends CommonEvent { diff --git a/app/features/calendar/components/TournamentCard.module.css b/app/features/calendar/components/TournamentCard.module.css index c969cfaf7..9a5f43a3c 100644 --- a/app/features/calendar/components/TournamentCard.module.css +++ b/app/features/calendar/components/TournamentCard.module.css @@ -195,3 +195,8 @@ padding: 0 var(--s-1-5); height: var(--selector-size); } + +.trophyPreview { + background-color: var(--color-bg-high); + border-radius: var(--radius-box); +} diff --git a/app/features/calendar/components/TournamentCard.tsx b/app/features/calendar/components/TournamentCard.tsx index b11400c8e..c602d8018 100644 --- a/app/features/calendar/components/TournamentCard.tsx +++ b/app/features/calendar/components/TournamentCard.tsx @@ -1,5 +1,5 @@ import clsx from "clsx"; -import { ShieldMinus, Trophy, Users } from "lucide-react"; +import { ShieldMinus, Trophy as TrophyIcon, Users } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import { SendouButton } from "~/components/elements/Button"; @@ -9,6 +9,7 @@ import { Image, ModeImage } from "~/components/Image"; import { LocaleTime } from "~/components/LocaleTime"; import { TierPill } from "~/components/TierPill"; import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay"; +import { Trophy } from "~/features/trophies/components/Trophy"; import { useFormatDistanceToNow } from "~/hooks/intl/useFormatDistanceToNow"; import { useHydrated } from "~/hooks/useHydrated"; import { useSpoilerFree } from "~/hooks/useSpoilerFree"; @@ -142,11 +143,16 @@ export function TournamentCard({ > {tournament.isRanked ? (
    - +
    ) : null} - {isCalendar && tournament.badges && tournament.badges.length > 0 ? ( - + {isCalendar && + (tournament.trophy || + (tournament.badges && tournament.badges.length > 0)) ? ( + ) : null} {isHostedOnSendouInk ? (
    @@ -292,10 +298,12 @@ function ModesPill({ modes }: { modes: NonNullable }) { ); } -function BadgePrizesPill({ +function PrizesPill({ badges, + trophy, }: { - badges: NonNullable; + badges: CalendarEvent["badges"]; + trophy?: string; }) { return ( Badge prizes } > - + {trophy ? ( + + ) : badges ? ( + + ) : null} ); } diff --git a/app/features/calendar/core/CalendarEvent.test.ts b/app/features/calendar/core/CalendarEvent.test.ts index c18a41198..8220ad7da 100644 --- a/app/features/calendar/core/CalendarEvent.test.ts +++ b/app/features/calendar/core/CalendarEvent.test.ts @@ -22,6 +22,7 @@ function makeEvent( type: "calendar", normalizedTeamCount: 0, badges: [], + trophy: null, logoUrl: null, name: "", url: "", diff --git a/app/features/calendar/loaders/calendar.new.server.ts b/app/features/calendar/loaders/calendar.new.server.ts index ac8980549..ac9f61a56 100644 --- a/app/features/calendar/loaders/calendar.new.server.ts +++ b/app/features/calendar/loaders/calendar.new.server.ts @@ -7,6 +7,8 @@ import * as CalendarRepository from "~/features/calendar/CalendarRepository.serv import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; import { tournamentData } from "~/features/tournament-bracket/core/Tournament.server"; import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server"; +import * as TrophyRepository from "~/features/trophies/TrophyRepository.server"; +import { canAccessTrophies } from "~/features/trophies/trophies-utils"; import { requireRole } from "~/modules/permissions/guards.server"; import { tournamentBracketsPage } from "~/utils/urls"; import { canEditCalendarEvent } from "../calendar-utils"; @@ -23,6 +25,7 @@ export const loader = async ({ url }: LoaderFunctionArgs) => { includeMapPool: true, includeTieBreakerMapPool: true, includeBadgePrizes: true, + includeTrophy: true, }); if (!event) return; @@ -84,12 +87,25 @@ export const loader = async ({ url }: LoaderFunctionArgs) => { ? await eventWithTournament("copyEventId") : undefined; + const validOrganizationIds = organizations.flatMap((org) => + typeof org === "string" ? [] : [org.id], + ); + + const trophies = canAccessTrophies(user) + ? await TrophyRepository.findByOrganizationIds(validOrganizationIds) + : []; + const eventToCopy = eventToCopyRaw ? { ...eventToCopyRaw, badgePrizes: eventToCopyRaw.badgePrizes?.filter((badge) => managedBadges.some((mb) => mb.id === badge.id), ), + trophy: eventToCopyRaw.trophy + ? trophies.some((t) => t.id === eventToCopyRaw.trophy?.id) + ? eventToCopyRaw.trophy + : null + : null, } : undefined; @@ -120,6 +136,7 @@ export const loader = async ({ url }: LoaderFunctionArgs) => { ? await CalendarRepository.findRecentTournamentsByAuthorId(user.id) : undefined, organizations, + trophies, }; }; diff --git a/app/features/calendar/loaders/calendar.server.ts b/app/features/calendar/loaders/calendar.server.ts index 17415f2a5..d3f9d394d 100644 --- a/app/features/calendar/loaders/calendar.server.ts +++ b/app/features/calendar/loaders/calendar.server.ts @@ -7,6 +7,7 @@ import { calendarFiltersSearchParamsObject, calendarFiltersSearchParamsSchema, } from "~/features/calendar/calendar-schemas"; +import { canAccessTrophies } from "~/features/trophies/trophies-utils"; import type { SerializeFrom } from "~/utils/remix"; import { parseSafeSearchParams, parseSearchParams } from "~/utils/remix.server"; import { dayMonthYear } from "~/utils/zod"; @@ -38,8 +39,24 @@ export const loader = async (args: LoaderFunctionArgs) => { const filters = resolveFilters(args.request, user?.preferences); const filtered = CalendarEvent.applyFilters(events, filters); + const eventTimes = canAccessTrophies(user) + ? filtered + : filtered.map((time) => ({ + ...time, + events: { + shown: time.events.shown.map((event) => ({ + ...event, + trophy: null, + })), + hidden: time.events.hidden.map((event) => ({ + ...event, + trophy: null, + })), + }, + })); + return { - eventTimes: filtered, + eventTimes, dateViewed: parsed.success ? parsed.data : undefined, filters, }; diff --git a/app/features/calendar/routes/calendar.new.tsx b/app/features/calendar/routes/calendar.new.tsx index 9256e3f33..c4da05626 100644 --- a/app/features/calendar/routes/calendar.new.tsx +++ b/app/features/calendar/routes/calendar.new.tsx @@ -1,3 +1,4 @@ +import { Trash } from "lucide-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; import type { MetaFunction } from "react-router"; @@ -13,6 +14,7 @@ import { SubmitButton } from "~/components/SubmitButton"; import type { Tables } from "~/db/tables"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; import * as Progression from "~/features/tournament-bracket/core/Progression"; +import { Trophy } from "~/features/trophies/components/Trophy"; import { type CustomFieldRenderProps, FormField } from "~/form/FormField"; import { existingImage } from "~/form/image-field"; import { SendouForm, useFormFieldContext } from "~/form/SendouForm"; @@ -26,6 +28,7 @@ import type { SendouRouteHandle } from "~/utils/remix.server"; import { CREATING_TOURNAMENT_DOC_LINK, FAQ_PAGE } from "~/utils/urls"; import { action } from "../actions/calendar.new.server"; import type { RegClosesAtOption } from "../calendar-constants"; +import styles from "../calendar-new.module.css"; import { calendarNewBaseSchema } from "../calendar-new-schemas"; import { datesToRegClosesAt } from "../calendar-utils"; import { BracketProgressionSelector } from "../components/BracketProgressionSelector"; @@ -194,6 +197,7 @@ function useDefaultValues() { discordInviteCode: baseEvent?.discordInviteCode ?? "", tags: baseEvent?.tags ?? [], badges: baseEvent?.badgePrizes?.map((b) => b.id) ?? [], + trophyId: baseEvent?.trophy?.id ?? null, avatarImgId: existingImage( baseEvent?.avatarImgId, baseEvent?.tournament?.ctx.logoUrl, @@ -291,6 +295,7 @@ function CalendarNewFields() { {data.badgeOptions.length > 0 ? ( ) : null} + {isTournament ? : null} {isTournament ? : null} {isTournament ? ( <> @@ -331,6 +336,97 @@ function DescriptionField({ isTournament }: { isTournament: boolean }) { ); } +function TrophyField() { + const { t } = useTranslation("calendar"); + const data = useLoaderData(); + const { values, setValue } = useFormFieldContext(); + const id = React.useId(); + + const organizationId = values.organizationId + ? Number(values.organizationId) + : null; + const trophyId = typeof values.trophyId === "number" ? values.trophyId : null; + const badgeCount = (values.badges as number[]).length; + + // clear the trophy when the selected organization or badges make it invalid + React.useEffect(() => { + if (!trophyId) return; + const trophyStillValid = + badgeCount === 0 && + data.trophies.some( + (trophy) => + trophy.id === trophyId && trophy.organizationId === organizationId, + ); + if (!trophyStillValid) { + setValue("trophyId", null); + } + }, [trophyId, badgeCount, organizationId, data.trophies, setValue]); + + const availableTrophies = organizationId + ? data.trophies.filter((trophy) => trophy.organizationId === organizationId) + : []; + + if (availableTrophies.length === 0 && trophyId === null) return null; + + const selectedTrophy = trophyId + ? data.trophies.find((trophy) => trophy.id === trophyId) + : null; + + return ( + + {({ onChange }: CustomFieldRenderProps) => { + const handleChange = (newTrophyId: number | null) => { + onChange(newTrophyId); + if (newTrophyId) { + setValue("badges", []); + } + }; + + return ( +
    +
    + + +
    + {selectedTrophy ? ( +
    + +
    + {selectedTrophy.name} + handleChange(null)} + icon={} + variant="minimal-destructive" + aria-label="Remove trophy" + /> +
    +
    + ) : null} +
    + ); + }} +
    + ); +} + function MemberCountFields() { const { values } = useFormFieldContext(); diff --git a/app/features/components-showcase/components-showcase.module.css b/app/features/components-showcase/components-showcase.module.css index 271f9e30c..ada31b8d2 100644 --- a/app/features/components-showcase/components-showcase.module.css +++ b/app/features/components-showcase/components-showcase.module.css @@ -23,3 +23,15 @@ .componentContent { width: 100%; } + +.trophyExample { + width: 140px; +} + +.trophyExampleSmall { + width: 90px; +} + +.trophyExampleLarge { + width: 200px; +} diff --git a/app/features/components-showcase/example-trophy-model.ts b/app/features/components-showcase/example-trophy-model.ts new file mode 100644 index 000000000..8fadb2227 --- /dev/null +++ b/app/features/components-showcase/example-trophy-model.ts @@ -0,0 +1,2 @@ +export const EXAMPLE_TROPHY_MODEL = + "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"; diff --git a/app/features/components-showcase/routes/components.tsx b/app/features/components-showcase/routes/components.tsx index a989b7621..fa0a26048 100644 --- a/app/features/components-showcase/routes/components.tsx +++ b/app/features/components-showcase/routes/components.tsx @@ -1,4 +1,5 @@ import { parseDate } from "@internationalized/date"; +import clsx from "clsx"; import { Check, Plus, Search, SquarePen, Trash } from "lucide-react"; import { useState } from "react"; import { Ability } from "~/components/Ability"; @@ -48,7 +49,12 @@ import { StageSelect } from "~/components/StageSelect"; import { SubmitButton } from "~/components/SubmitButton"; import { SubNav, SubNavLink } from "~/components/SubNav"; import { Table } from "~/components/Table"; +import { TierPill } from "~/components/TierPill"; import { WeaponSelect } from "~/components/WeaponSelect"; +import { + Trophy, + TrophyContextProvider, +} from "~/features/trophies/components/Trophy"; import { UserCard } from "~/features/user-card/components/UserCard"; import type { UserCardData } from "~/features/user-card/user-card-types"; import type { CustomFieldRenderProps } from "~/form/FormField"; @@ -56,6 +62,7 @@ import { SendouForm } from "~/form/SendouForm"; import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types"; import type { SendouRouteHandle } from "~/utils/remix.server"; import styles from "../components-showcase.module.css"; +import { EXAMPLE_TROPHY_MODEL } from "../example-trophy-model"; import { formFieldsShowcaseSchema } from "../form-examples-schema"; export const handle: SendouRouteHandle = { @@ -102,6 +109,8 @@ export const SECTIONS = [ { title: "Flags", id: "flags", component: FlagSection }, { title: "Placements", id: "placements", component: PlacementSection }, { title: "Badges", id: "badges", component: BadgeSection }, + { title: "Trophies", id: "trophies", component: TrophySection }, + { title: "Tier Pills", id: "tier-pills", component: TierPillSection }, { title: "Game Selects", id: "game-selects", component: GameSelectSection }, { title: "Form Fields", id: "form-fields", component: FormFieldsSection }, { title: "Miscellaneous", id: "miscellaneous", component: MiscSection }, @@ -2048,6 +2057,115 @@ function BadgeSection({ id }: { id: string }) { ); } +function TrophySection({ id }: { id: string }) { + return ( +
    + Trophies + + +
    + + + + + + + + + +
    + {([1, 4, 9] as const).map((tier) => ( + + ))} +
    +
    + + + + + + +
    + + + +
    +
    +
    +
    +
    + ); +} + +function TierPillSection({ id }: { id: string }) { + return ( +
    + Tier Pills + +
    + +
    + {[1, 2, 3, 4, 5, 6, 7, 8, 9].map((tier) => ( + + ))} +
    +
    + + +
    + {[1, 4, 9].map((tier) => ( + + ))} +
    +
    +
    +
    + ); +} + function GameSelectSection({ id }: { id: string }) { const [selectedWeapon, setSelectedWeapon] = useState( null, diff --git a/app/features/front-page/routes/index.tsx b/app/features/front-page/routes/index.tsx index 59918949a..900024895 100644 --- a/app/features/front-page/routes/index.tsx +++ b/app/features/front-page/routes/index.tsx @@ -21,6 +21,7 @@ import { PWAInstallBanner } from "~/features/front-page/components/PWAInstallBan import { SplatoonRotations } from "~/features/front-page/components/SplatoonRotations"; import type * as Changelog from "~/features/front-page/core/Changelog.server"; import * as Seasons from "~/features/mmr/core/Seasons"; +import { canAccessTrophies } from "~/features/trophies/trophies-utils"; import styles from "~/styles/front.module.css"; import { databaseTimestampToDate } from "~/utils/dates"; import type { SendouRouteHandle } from "~/utils/remix.server"; @@ -263,7 +264,7 @@ function ResultHighlights() { > {t("front:showcase.results")} -
    +
    {data.tournaments.results.map((tournament) => ( ))} @@ -316,9 +317,12 @@ const DISCOVER_EXCLUDED_ITEMS = new Set(["settings", "luti"]); function DiscoverFeatures() { const { t } = useTranslation(["front", "common"]); const data = useLoaderData(); + const user = useUser(); const filteredNavItems = navItems.filter( - (item) => !DISCOVER_EXCLUDED_ITEMS.has(item.name), + (item) => + !DISCOVER_EXCLUDED_ITEMS.has(item.name) && + (item.name !== "trophies" || canAccessTrophies(user)), ); return ( diff --git a/app/features/notifications/core/notify.server.ts b/app/features/notifications/core/notify.server.ts index 30aa4dc9f..ef9b48395 100644 --- a/app/features/notifications/core/notify.server.ts +++ b/app/features/notifications/core/notify.server.ts @@ -22,6 +22,9 @@ const NOTIFICATION_URGENCY: Record = { TO_LIKE_ACCEPTED: "high", BADGE_ADDED: "normal", BADGE_MANAGER_ADDED: "normal", + TROPHY_SUBMITTED: "normal", + TROPHY_SUBMISSION_ACCEPTED: "normal", + TROPHY_SUBMISSION_DECLINED: "normal", PLUS_VOTING_STARTED: "normal", PLUS_SUGGESTION_ADDED: "normal", TAGGED_TO_ART: "normal", diff --git a/app/features/notifications/notifications-types.ts b/app/features/notifications/notifications-types.ts index 7c47aa34f..0cd12fdc5 100644 --- a/app/features/notifications/notifications-types.ts +++ b/app/features/notifications/notifications-types.ts @@ -49,6 +49,15 @@ export type Notification = "BADGE_MANAGER_ADDED", { badgeName: string; badgeId: number } > + | NotificationItem< + "TROPHY_SUBMITTED", + { trophyName: string; submitterUsername: string } + > + | NotificationItem< + "TROPHY_SUBMISSION_ACCEPTED", + { trophyName: string; trophyId: number } + > + | NotificationItem<"TROPHY_SUBMISSION_DECLINED", { trophyName: string }> | NotificationItem< "PLUS_VOTING_STARTED", { diff --git a/app/features/notifications/notifications-utils.ts b/app/features/notifications/notifications-utils.ts index d2336245b..19f4a15ba 100644 --- a/app/features/notifications/notifications-utils.ts +++ b/app/features/notifications/notifications-utils.ts @@ -2,6 +2,7 @@ import { assertUnreachable } from "~/utils/types"; import { badgePage, FRIENDS_PAGE, + NEW_TROPHY_PAGE, PLUS_VOTING_PAGE, plusSuggestionPage, SENDOUQ_PAGE, @@ -12,6 +13,7 @@ import { tournamentRegisterPage, tournamentSubsPage, tournamentTeamPage, + trophyPage, userArtPage, userEditProfilePage, } from "~/utils/urls"; @@ -22,6 +24,10 @@ export const notificationNavIcon = (type: Notification["type"]) => { case "BADGE_ADDED": case "BADGE_MANAGER_ADDED": return "badges"; + case "TROPHY_SUBMITTED": + case "TROPHY_SUBMISSION_ACCEPTED": + case "TROPHY_SUBMISSION_DECLINED": + return "trophies"; case "PLUS_SUGGESTION_ADDED": case "PLUS_VOTING_STARTED": return "plus"; @@ -58,6 +64,11 @@ export const notificationLink = (notification: Notification) => { return badgePage(notification.meta.badgeId); case "BADGE_MANAGER_ADDED": return badgePage(notification.meta.badgeId); + case "TROPHY_SUBMITTED": + case "TROPHY_SUBMISSION_DECLINED": + return NEW_TROPHY_PAGE; + case "TROPHY_SUBMISSION_ACCEPTED": + return trophyPage(notification.meta.trophyId); case "PLUS_SUGGESTION_ADDED": return plusSuggestionPage({ tier: notification.meta.tier }); case "PLUS_VOTING_STARTED": diff --git a/app/features/search/routes/search.ts b/app/features/search/routes/search.ts index b1fb9473d..7872cdf20 100644 --- a/app/features/search/routes/search.ts +++ b/app/features/search/routes/search.ts @@ -76,10 +76,16 @@ async function searchByType({ })); } case "organizations": { - const orgs = await TournamentOrganizationRepository.searchByName({ - query, - limit, - }); + const numericQuery = /^\d+$/.test(query) ? Number(query) : null; + const orgs = numericQuery + ? await TournamentOrganizationRepository.findOneById(numericQuery).then( + (o) => (o ? [o] : []), + ) + : await TournamentOrganizationRepository.searchByName({ + query, + limit, + }); + return orgs.map((o) => ({ type: "organization" as const, id: o.id, diff --git a/app/features/tier-list-maker/hooks/useTierList.ts b/app/features/tier-list-maker/hooks/useTierList.ts index 4db4dcaa9..70cdc63f1 100644 --- a/app/features/tier-list-maker/hooks/useTierList.ts +++ b/app/features/tier-list-maker/hooks/useTierList.ts @@ -4,7 +4,6 @@ import type { DragStartEvent, } from "@dnd-kit/core"; import { arrayMove } from "@dnd-kit/sortable"; -import JSONCrush from "jsoncrush"; import * as React from "react"; import { useSearchParams } from "react-router"; import { z } from "zod"; @@ -31,7 +30,12 @@ import { tierListItemTypeSchema, tierListStateSerializedSchema, } from "../tier-list-maker-schemas"; -import { addItemToTier, getNextNthForItem } from "../tier-list-maker-utils"; +import { + addItemToTier, + compress, + decompress, + getNextNthForItem, +} from "../tier-list-maker-utils"; export type TierListPlacementMode = "track" | "click"; @@ -517,11 +521,10 @@ function useSearchParamTiersState() { try { if (param) { - const uncrushed = JSONCrush.uncrush(param); + const decompressed = decompress(param); + if (decompressed === null) throw new Error("Failed to decompress"); - const parsed = tierListStateSerializedSchema.parse( - JSON.parse(uncrushed), - ); + const parsed = tierListStateSerializedSchema.parse(decompressed); return { tiers: parsed.tiers, @@ -539,12 +542,14 @@ function useSearchParamTiersState() { const persistTiersStateToParams = (state: TierListState) => { const searchParams = new URLSearchParams(window.location.search); - const serializedState = JSON.stringify({ - tiers: state.tiers, - tierItems: Array.from(state.tierItems.entries()), - }); + searchParams.set( + TIER_SEARCH_PARAM_NAME, + compress({ + tiers: state.tiers, + tierItems: Array.from(state.tierItems.entries()), + }), + ); - searchParams.set(TIER_SEARCH_PARAM_NAME, JSONCrush.crush(serializedState)); window.history.replaceState( {}, "", diff --git a/app/features/tier-list-maker/tier-list-maker-utils.ts b/app/features/tier-list-maker/tier-list-maker-utils.ts index e56babbea..c754da7b5 100644 --- a/app/features/tier-list-maker/tier-list-maker-utils.ts +++ b/app/features/tier-list-maker/tier-list-maker-utils.ts @@ -1,3 +1,4 @@ +import { compressToBase64, decompressFromBase64 } from "~/utils/compression"; import type { TierListItem, TierListState } from "./tier-list-maker-schemas"; export function tierListItemId(item: TierListItem) { @@ -50,3 +51,18 @@ export function getNextNthForItem( }, 0) + 1 ); } + +export function compress(obj: T) { + return compressToBase64(JSON.stringify(obj), { urlSafe: true }); +} + +export function decompress(compressed: string) { + const json = decompressFromBase64(compressed); + if (json === null) return null; + + try { + return JSON.parse(json) as T; + } catch { + return null; + } +} diff --git a/app/features/top-search/actions/xsearch.player.$id.server.ts b/app/features/top-search/actions/xsearch.player.$id.server.ts index 5845ef91e..38ccd91ca 100644 --- a/app/features/top-search/actions/xsearch.player.$id.server.ts +++ b/app/features/top-search/actions/xsearch.player.$id.server.ts @@ -1,6 +1,7 @@ import type { ActionFunctionArgs } from "react-router"; import { requireUser } from "~/features/auth/core/user.server"; import * as BadgeRepository from "~/features/badges/BadgeRepository.server"; +import * as TrophyRepository from "~/features/trophies/TrophyRepository.server"; import { logger } from "~/utils/logger"; import { errorToastIfFalsy, @@ -36,6 +37,7 @@ export const action = async ({ params }: ActionFunctionArgs) => { await XRankPlacementRepository.unlinkPlayerByUserId(user.id); await BadgeRepository.syncXPBadges(); + await TrophyRepository.syncSpecialTrophies(); await XRankPlacementRepository.refreshTenStarWeapons(user.id); return successToast("Unlink successful"); diff --git a/app/features/tournament-bracket/actions/to.$id.brackets.finalize.server.ts b/app/features/tournament-bracket/actions/to.$id.brackets.finalize.server.ts index 8c0b09cf5..00b428d93 100644 --- a/app/features/tournament-bracket/actions/to.$id.brackets.finalize.server.ts +++ b/app/features/tournament-bracket/actions/to.$id.brackets.finalize.server.ts @@ -22,8 +22,12 @@ import { import { finalizeTournamentActionSchema, type TournamentBadgeReceivers, + type TournamentTrophyReceiver, } from "~/features/tournament-bracket/tournament-bracket-schemas.server"; -import { validateBadgeReceivers } from "~/features/tournament-bracket/tournament-bracket-utils"; +import { + validateBadgeReceivers, + validateTrophyReceiver, +} from "~/features/tournament-bracket/tournament-bracket-utils"; import * as TournamentMatchRepository from "~/features/tournament-match/TournamentMatchRepository.server"; import { refreshTentativeTiersCache } from "~/features/tournament-organization/core/tentativeTiers.server"; import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server"; @@ -53,8 +57,18 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { errorToastIfFalsy(tournament.canFinalize(user), "Can't finalize tournament"); + const event = await CalendarRepository.findById(tournament.ctx.eventId, { + includeBadgePrizes: true, + includeTrophy: true, + }); + invariant(event, "Event not found for tournament"); + const badgeOwnersValid = data.badgeReceivers - ? await requireValidBadgeReceivers(data.badgeReceivers, tournament) + ? requireValidBadgeReceivers({ + badgeReceivers: data.badgeReceivers, + badges: event.badgePrizes ?? [], + tournament, + }) : true; if (!badgeOwnersValid) errorToast("New badge owners invalid"); @@ -68,6 +82,17 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { const standingsResult = Standings.tournamentStandings(tournament); const finalStandings = Standings.flattenStandings(standingsResult); + const trophyReceiver = event.trophy ? (data.trophyReceiver ?? null) : null; + if (event.trophy) { + const trophyReceiverValid = requireValidTrophyReceiver({ + trophyReceiver, + trophy: event.trophy, + finalStandings, + tournament, + }); + if (!trophyReceiverValid) errorToast("Invalid trophy receiver"); + } + const calculateSeasonalStats = tournament.ranked && typeof season === "number"; const ratingTargets = summaryRatingTargets(results); @@ -103,6 +128,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { summary, season, badgeReceivers: data.badgeReceivers ?? undefined, + trophyReceiver: trophyReceiver ?? undefined, }); } else { logger.info( @@ -133,6 +159,12 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { notifyBadgeReceivers(data.badgeReceivers); } + if (trophyReceiver) { + logger.info( + `Trophy receiver for tournament id ${tournamentId}: ${JSON.stringify(trophyReceiver)}`, + ); + } + clearTournamentDataCache(tournamentId); // ensure RunningTournament = sidebar updates @@ -144,17 +176,15 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { }); }; -async function requireValidBadgeReceivers( - badgeReceivers: TournamentBadgeReceivers, - tournament: Tournament, -) { - const badges = ( - await CalendarRepository.findById(tournament.ctx.eventId, { - includeBadgePrizes: true, - }) - )?.badgePrizes; - invariant(badges, "validateBadgeOwners: Event with badge prizes not found"); - +function requireValidBadgeReceivers({ + badgeReceivers, + badges, + tournament, +}: { + badgeReceivers: TournamentBadgeReceivers; + badges: ReadonlyArray<{ id: number }>; + tournament: Tournament; +}) { const error = validateBadgeReceivers({ badgeReceivers, badges, @@ -170,6 +200,57 @@ async function requireValidBadgeReceivers( return true; } +function requireValidTrophyReceiver({ + trophyReceiver, + trophy, + finalStandings, + tournament, +}: { + trophyReceiver: TournamentTrophyReceiver | null; + trophy: { id: number }; + finalStandings: Array<{ + placement: number; + team: { members: Array<{ userId: number }> }; + }>; + tournament: Tournament; +}) { + const error = validateTrophyReceiver({ trophyReceiver, trophy }); + if (error) { + logger.warn( + `validateTrophyReceiver: Invalid trophy receiver for tournament ${tournament.ctx.id}: ${error}`, + ); + return false; + } + + if (!trophyReceiver) return true; + + const firstPlace = finalStandings.find( + (standing) => standing.placement === 1, + ); + if (!firstPlace) { + logger.warn( + `validateTrophyReceiver: No 1st place standing for tournament ${tournament.ctx.id}`, + ); + return false; + } + + const firstPlaceUserIds = new Set( + firstPlace.team.members.map((m) => m.userId), + ); + const invalidUserId = trophyReceiver.userIds.find( + (userId) => !firstPlaceUserIds.has(userId), + ); + + if (invalidUserId !== undefined) { + logger.warn( + `validateTrophyReceiver: User ${invalidUserId} not in 1st place team for tournament ${tournament.ctx.id}`, + ); + return false; + } + + return true; +} + async function notifyBadgeReceivers(badgeReceivers: TournamentBadgeReceivers) { try { for (const receiver of badgeReceivers) { diff --git a/app/features/tournament-bracket/loaders/to.$id.brackets.finalize.server.ts b/app/features/tournament-bracket/loaders/to.$id.brackets.finalize.server.ts index d2b378964..a25146279 100644 --- a/app/features/tournament-bracket/loaders/to.$id.brackets.finalize.server.ts +++ b/app/features/tournament-bracket/loaders/to.$id.brackets.finalize.server.ts @@ -34,19 +34,21 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { ); } - const badges = ( - await CalendarRepository.findById(tournament.ctx.eventId, { - includeBadgePrizes: true, - }) - )?.badgePrizes?.sort((a, b) => a.id - b.id); + const event = await CalendarRepository.findById(tournament.ctx.eventId, { + includeBadgePrizes: true, + includeTrophy: true, + }); invariant( - badges, + event?.badgePrizes, `Tournament ${tournament.ctx.id} event not found for badges`, ); + const badges = event.badgePrizes.sort((a, b) => a.id - b.id); + return { badges, + trophy: event.trophy, standings: await standingsWithSetParticipation(tournament), }; }; diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.finalize.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.finalize.tsx index dd92e37c6..53fbf3bd7 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.finalize.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.finalize.tsx @@ -10,8 +10,15 @@ import { FormMessage } from "~/components/FormMessage"; import { Placement } from "~/components/Placement"; import { SubmitButton } from "~/components/SubmitButton"; import { useTournament } from "~/features/tournament/routes/to.$id"; -import type { TournamentBadgeReceivers } from "~/features/tournament-bracket/tournament-bracket-schemas.server"; -import { validateBadgeReceivers } from "~/features/tournament-bracket/tournament-bracket-utils"; +import type { + TournamentBadgeReceivers, + TournamentTrophyReceiver, +} from "~/features/tournament-bracket/tournament-bracket-schemas.server"; +import { + validateBadgeReceivers, + validateTrophyReceiver, +} from "~/features/tournament-bracket/tournament-bracket-utils"; +import { Trophy } from "~/features/trophies/components/Trophy"; import { ParticipationPill } from "~/features/user-page/components/ParticipationPill"; import invariant from "~/utils/invariant"; import { action } from "../actions/to.$id.brackets.finalize.server"; @@ -26,19 +33,48 @@ export default function TournamentFinalizePage() { const data = useLoaderData(); const { t } = useTranslation(["tournament"]); const location = useLocation(); - const [isAssignLaterSelected, setIsAssignLaterSelected] = + const tournament = useTournament(); + const firstPlaceStanding = data.standings.find( + (standing) => standing.placement === 1, + ); + const trophyDefaultUserIds = + data.trophy && firstPlaceStanding + ? tournament.minMembersPerTeam === firstPlaceStanding.members.length + ? firstPlaceStanding.members.map((m) => m.userId) + : [] + : []; + + const [isAssignBadgesLaterSelected, setIsAssignBadgesLaterSelected] = React.useState(false); const [badgeReceivers, setBadgeReceivers] = React.useState([]); + const [trophyReceiverUserIds, setTrophyReceiverUserIds] = + React.useState>(trophyDefaultUserIds); const bracketUrl = location.pathname.replace(/\/finalize$/, ""); const tournamentHasBadges = data.badges.length > 0; + const tournamentHasTrophy = Boolean(data.trophy); - const badgesError = !isAssignLaterSelected - ? validateBadgeReceivers({ badgeReceivers, badges: data.badges }) + const trophyReceiver: TournamentTrophyReceiver | null = + data.trophy && firstPlaceStanding + ? { trophyId: data.trophy.id, userIds: trophyReceiverUserIds } + : null; + + const badgesError = + !isAssignBadgesLaterSelected && !tournamentHasTrophy + ? validateBadgeReceivers({ badgeReceivers, badges: data.badges }) + : null; + + const trophyError = tournamentHasTrophy + ? validateTrophyReceiver({ + trophyReceiver, + trophy: data.trophy ?? null, + }) : null; + const error = badgesError ?? trophyError; + return ( - {tournamentHasBadges ? ( + {tournamentHasTrophy && data.trophy && firstPlaceStanding ? ( + <> + + + + ) : tournamentHasBadges ? ( <> {t("tournament:actions.finalize.assignBadgesLater")} - {!isAssignLaterSelected ? ( + {!isAssignBadgesLaterSelected ? ( <> ; + error: + | ReturnType + | ReturnType; isAssigningBadges: boolean; }) { const fetcher = useFetcher(); @@ -248,3 +304,52 @@ function NewBadgeReceiversSelector({
    ); } + +function NewTrophyReceiversSelector({ + trophy, + firstPlaceStanding, + trophyReceiverUserIds, + setTrophyReceiverUserIds, +}: { + trophy: NonNullable; + firstPlaceStanding: FinalizeTournamentLoaderData["standings"][number]; + trophyReceiverUserIds: Array; + setTrophyReceiverUserIds: (userIds: Array) => void; +}) { + const handleReceiverSelected = (userId: number) => (isSelected: boolean) => { + if (isSelected) { + setTrophyReceiverUserIds([...trophyReceiverUserIds, userId]); + } else { + setTrophyReceiverUserIds( + trophyReceiverUserIds.filter((id) => id !== userId), + ); + } + }; + + return ( +
    + + + {firstPlaceStanding.members.map((member, i) => { + return ( +
    +
    + + + {member.username} +
    +
    + +
    + {i !== firstPlaceStanding.members.length - 1 ? ( + + ) : null} +
    + ); + })} +
    + ); +} diff --git a/app/features/tournament-bracket/tournament-bracket-schemas.server.ts b/app/features/tournament-bracket/tournament-bracket-schemas.server.ts index ddc70e3f4..53f586390 100644 --- a/app/features/tournament-bracket/tournament-bracket-schemas.server.ts +++ b/app/features/tournament-bracket/tournament-bracket-schemas.server.ts @@ -183,6 +183,14 @@ const badgeReceivers = z.array( }), ); +export type TournamentTrophyReceiver = z.infer; + +const trophyReceiver = z.object({ + trophyId: id, + userIds: z.array(id).min(1).max(50), +}); + export const finalizeTournamentActionSchema = z.object({ badgeReceivers: z.preprocess(safeJSONParse, badgeReceivers.nullish()), + trophyReceiver: z.preprocess(safeJSONParse, trophyReceiver.nullish()), }); diff --git a/app/features/tournament-bracket/tournament-bracket-utils.ts b/app/features/tournament-bracket/tournament-bracket-utils.ts index 77b009818..16c9695fd 100644 --- a/app/features/tournament-bracket/tournament-bracket-utils.ts +++ b/app/features/tournament-bracket/tournament-bracket-utils.ts @@ -1,4 +1,7 @@ -import type { TournamentBadgeReceivers } from "~/features/tournament-bracket/tournament-bracket-schemas.server"; +import type { + TournamentBadgeReceivers, + TournamentTrophyReceiver, +} from "~/features/tournament-bracket/tournament-bracket-schemas.server"; import type { TournamentLoaderData } from "../tournament/loaders/to.$id.server"; import type { Standing } from "./core/Bracket"; @@ -106,3 +109,23 @@ export function validateBadgeReceivers({ return null; } + +export function validateTrophyReceiver({ + trophyReceiver, + trophy, +}: { + trophyReceiver: TournamentTrophyReceiver | null; + trophy: { id: number } | null; +}) { + if (!trophy) return null; + + if (!trophyReceiver || trophyReceiver.trophyId !== trophy.id) { + return "TROPHY_NOT_FOUND"; + } + + if (trophyReceiver.userIds.length === 0) { + return "TROPHY_NOT_ASSIGNED"; + } + + return null; +} diff --git a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts index 327afb435..38ef6979b 100644 --- a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts +++ b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts @@ -213,6 +213,26 @@ export function searchByName({ .execute(); } +export function findOneById(id: number) { + return db + .selectFrom("TournamentOrganization") + .leftJoin( + "UserSubmittedImage", + "UserSubmittedImage.id", + "TournamentOrganization.avatarImgId", + ) + .select(({ eb }) => [ + "TournamentOrganization.id", + "TournamentOrganization.name", + "TournamentOrganization.slug", + concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as( + "avatarUrl", + ), + ]) + .where("TournamentOrganization.id", "=", id) + .executeTakeFirst(); +} + interface FindEventsByMonthArgs { month: number; year: number; diff --git a/app/features/tournament-organization/loaders/org.$slug.server.ts b/app/features/tournament-organization/loaders/org.$slug.server.ts index e46bd3da8..82de97f38 100644 --- a/app/features/tournament-organization/loaders/org.$slug.server.ts +++ b/app/features/tournament-organization/loaders/org.$slug.server.ts @@ -2,6 +2,8 @@ import type { LoaderFunctionArgs } from "react-router"; import { z } from "zod"; import { getUser } from "~/features/auth/core/user.server"; import { calculateTentativeTier } from "~/features/tournament/core/tiering"; +import * as TrophyRepository from "~/features/trophies/TrophyRepository.server"; +import { canAccessTrophies } from "~/features/trophies/trophies-utils"; import type { SerializeFrom } from "~/utils/remix"; import { parseSafeSearchParams } from "~/utils/remix.server"; import { id } from "~/utils/zod"; @@ -80,6 +82,9 @@ export async function loader({ params, request }: LoaderFunctionArgs) { series: await seriesInfo(), month, year, + trophies: canAccessTrophies(user) + ? await TrophyRepository.findByOrganizationId(organization.id) + : [], bannedUsers: user?.id && organization.permissions.BAN.includes(user.id) ? await TournamentOrganizationRepository.findAllBannedUsersByOrganizationId( diff --git a/app/features/tournament-organization/routes/org.$slug.tsx b/app/features/tournament-organization/routes/org.$slug.tsx index fef94a9fe..8e58aba0a 100644 --- a/app/features/tournament-organization/routes/org.$slug.tsx +++ b/app/features/tournament-organization/routes/org.$slug.tsx @@ -6,9 +6,10 @@ import { SquarePen, Users, } from "lucide-react"; +import * as React from "react"; import { useTranslation } from "react-i18next"; import type { MetaFunction } from "react-router"; -import { Link, useLoaderData, useSearchParams } from "react-router"; +import { Link, useFetcher, useLoaderData, useSearchParams } from "react-router"; import { Avatar } from "~/components/Avatar"; import { Divider } from "~/components/Divider"; import { LinkButton, SendouButton } from "~/components/elements/Button"; @@ -29,6 +30,16 @@ import { TierPill } from "~/components/TierPill"; import { useUser } from "~/features/auth/core/user"; import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay"; import { BannedUsersList } from "~/features/tournament-organization/components/BannedPlayersList"; +import { + Trophy, + TrophyContextProvider, + TrophyGrid, + TrophyPlaceholder, +} from "~/features/trophies/components/Trophy"; +import { TrophyShowcaseModal } from "~/features/trophies/components/TrophyShowcase"; +import { TrophyTournamentHistory } from "~/features/trophies/components/TrophyTournamentHistory"; +import type { TrophyTournamentsLoaderData } from "~/features/trophies/routes/trophies.$id.tournaments"; +import { useProgressiveRender } from "~/features/trophies/trophies-utils"; import { SendouForm } from "~/form/SendouForm"; import { useHasPermission, useHasRole } from "~/modules/permissions/hooks"; import { databaseTimestampNow, databaseTimestampToDate } from "~/utils/dates"; @@ -42,6 +53,7 @@ import { tournamentOrganizationPage, tournamentOrganizationStatsPage, tournamentPage, + trophyTournamentsPage, userPage, } from "~/utils/urls"; import { action } from "../actions/org.$slug.server"; @@ -71,7 +83,7 @@ export const meta: MetaFunction = (args) => { }; export const handle: SendouRouteHandle = { - i18n: ["badges", "org"], + i18n: ["badges", "org", "trophies"], breadcrumb: ({ match }) => { const data = match.loaderData as SerializeFrom | undefined; @@ -209,7 +221,7 @@ function LogoHeader() { } function InfoTabs() { - const { t } = useTranslation(["org"]); + const { t } = useTranslation(["org", "trophies"]); const data = useLoaderData(); const isAdmin = useHasRole("ADMIN"); const canBanPlayers = useHasPermission(data.organization, "BAN"); @@ -217,6 +229,8 @@ function InfoTabs() { const hasSocials = data.organization.socials && data.organization.socials.length > 0; const hasBadges = data.organization.badges.length > 0; + const hasTrophies = data.trophies.length > 0; + const hasRewards = hasBadges || hasTrophies; return (
    @@ -229,11 +243,11 @@ function InfoTabs() { {t("org:edit.form.members.title")} } + id="rewards" + isDisabled={!hasRewards} + icon={} > - {t("org:edit.form.badges.title")} + {t("org:edit.form.rewards.title")} {canBanPlayers && data.bannedUsers ? ( - - + + {data.bannedUsers ? ( @@ -274,6 +291,37 @@ function InfoTabs() { ); } +function RewardsPanel({ + badges, + trophies, +}: { + badges: SerializeFrom["organization"]["badges"]; + trophies: SerializeFrom["trophies"]; +}) { + const { t } = useTranslation(["org", "trophies"]); + + return ( +
    + {trophies.length > 0 ? ( + <> + + {t("trophies:title")} + + + + ) : null} + {badges.length > 0 ? ( + <> + + {t("org:edit.form.badges.title")} + + + + ) : null} +
    + ); +} + function AdminControls() { const data = useLoaderData(); @@ -692,3 +740,74 @@ function EventLeaderboardRow({
    ); } + +function RewardsTrophyGrid({ + trophies, +}: { + trophies: SerializeFrom["trophies"]; +}) { + const visibleCount = useProgressiveRender(trophies.length, ""); + const [openTrophy, setOpenTrophy] = React.useState< + SerializeFrom["trophies"][number] | null + >(null); + + return ( + + + {trophies.map((trophy, i) => + i < visibleCount ? ( + + ) : ( + + ), + )} + + {openTrophy ? ( + setOpenTrophy(null)} + > + + + ) : null} + + ); +} + +function TrophyModalTournaments({ trophyId }: { trophyId: number }) { + const { t } = useTranslation(["trophies"]); + const fetcher = useFetcher(); + + const loadedRef = React.useRef(false); + React.useEffect(() => { + if (loadedRef.current) return; + loadedRef.current = true; + fetcher.load(trophyTournamentsPage(trophyId)); + }, [fetcher.load, trophyId]); + + if (!fetcher.data || fetcher.data.tournaments.length === 0) return null; + + return ( +
    + {t("trophies:details.tournamentHistory")} + +
    + ); +} diff --git a/app/features/tournament-organization/tournament-organization.module.css b/app/features/tournament-organization/tournament-organization.module.css index 38e195a78..845bd7e0c 100644 --- a/app/features/tournament-organization/tournament-organization.module.css +++ b/app/features/tournament-organization/tournament-organization.module.css @@ -200,3 +200,23 @@ fill: #1285fe; } } + +.trophyGridButton { + all: unset; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.trophyModalTournaments { + display: flex; + flex-direction: column; + gap: var(--s-2); + opacity: 1; + transition: opacity 0.2s ease; + + @starting-style { + opacity: 0; + } +} diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index cb9316df3..8dc787058 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -13,7 +13,10 @@ import { actorId } from "~/features/auth/core/user.server"; import { identifierToUserIds } from "~/features/mmr/mmr-utils"; import * as Progression from "~/features/tournament-bracket/core/Progression"; import type { TournamentSummary } from "~/features/tournament-bracket/core/summarizer.server"; -import type { TournamentBadgeReceivers } from "~/features/tournament-bracket/tournament-bracket-schemas.server"; +import type { + TournamentBadgeReceivers, + TournamentTrophyReceiver, +} from "~/features/tournament-bracket/tournament-bracket-schemas.server"; import { modesShort } from "~/modules/in-game-lists/modes"; import { nullFilledArray, nullifyingAvg } from "~/utils/arrays"; import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates"; @@ -1078,11 +1081,13 @@ export function finalize({ summary, season, badgeReceivers = [], + trophyReceiver, }: { tournamentId: number; summary: TournamentSummary; season?: number; badgeReceivers?: TournamentBadgeReceivers; + trophyReceiver?: TournamentTrophyReceiver; }) { const seasonValue = season ?? null; @@ -1245,6 +1250,29 @@ export function finalize({ .execute(); } + if (trophyReceiver && trophyReceiver.userIds.length > 0) { + const tournamentRow = await trx + .selectFrom("Tournament") + .select("tier") + .where("id", "=", tournamentId) + .executeTakeFirst(); + + await trx + .insertInto("TrophyOwner") + .values( + trophyReceiver.userIds.map((userId) => ({ + tournamentId, + trophyId: trophyReceiver.trophyId, + userId, + tier: tournamentRow?.tier ?? null, + })), + ) + .onConflict((oc) => + oc.columns(["tournamentId", "userId", "trophyId"]).doNothing(), + ) + .execute(); + } + const tournamentResults = summary.tournamentResults .map((tournamentResult) => ({ tournamentResult, diff --git a/app/features/trophies/TrophyRepository.server.test.ts b/app/features/trophies/TrophyRepository.server.test.ts new file mode 100644 index 000000000..ded5ed322 --- /dev/null +++ b/app/features/trophies/TrophyRepository.server.test.ts @@ -0,0 +1,295 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { db } from "~/db/sql"; +import type { TournamentTierNumber } from "~/features/tournament/core/tiering"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; +import { dbInsertUsers, dbReset } from "~/utils/Test"; +import * as TrophyRepository from "./TrophyRepository.server"; + +describe("trophy approvals", () => { + let pendingTrophyId: number; + + beforeEach(async () => { + await dbInsertUsers(4); + await db + .insertInto("TournamentOrganization") + .values({ name: "Test Org", slug: "test-org" }) + .execute(); + + const pending = await TrophyRepository.createPending({ + name: "Test Trophy", + model: "model", + description: "", + organizationId: 1, + submitterUserId: 1, + }); + pendingTrophyId = pending.id; + }); + + afterEach(() => dbReset()); + + test("creates the trophy exactly once when approvals exceed the required count", async () => { + expect( + await TrophyRepository.addApproval({ pendingTrophyId, userId: 2 }), + ).toBe(null); + + const accepted = await TrophyRepository.addApproval({ + pendingTrophyId, + userId: 3, + }); + expect(accepted?.id).toBeTypeOf("number"); + + expect( + await TrophyRepository.addApproval({ pendingTrophyId, userId: 4 }), + ).toBe(null); + + expect(await trophyCount()).toBe(1); + }); + + test("ignores repeated approvals from the same user", async () => { + await TrophyRepository.addApproval({ pendingTrophyId, userId: 2 }); + + expect( + await TrophyRepository.addApproval({ pendingTrophyId, userId: 2 }), + ).toBe(null); + + const pending = await TrophyRepository.findPendingById(pendingTrophyId); + expect(pending?.approvals.length).toBe(1); + expect(await trophyCount()).toBe(0); + }); + + test("re-approval after acceptance does not create another trophy", async () => { + await TrophyRepository.addApproval({ pendingTrophyId, userId: 2 }); + await TrophyRepository.addApproval({ pendingTrophyId, userId: 3 }); + + expect( + await TrophyRepository.addApproval({ pendingTrophyId, userId: 2 }), + ).toBe(null); + + expect(await trophyCount()).toBe(1); + }); + + test("declines a pending trophy that is not accepted", async () => { + await TrophyRepository.addApproval({ pendingTrophyId, userId: 2 }); + + expect( + await TrophyRepository.declinePending({ + id: pendingTrophyId, + reason: "reason", + declinedByUserId: 3, + }), + ).toBe(true); + + const pending = await TrophyRepository.findPendingById(pendingTrophyId); + expect(pending?.declinedAt).not.toBe(null); + expect(pending?.approvals.length).toBe(0); + }); + + test("does not decline an already accepted pending trophy", async () => { + await TrophyRepository.addApproval({ pendingTrophyId, userId: 2 }); + await TrophyRepository.addApproval({ pendingTrophyId, userId: 3 }); + + expect( + await TrophyRepository.declinePending({ + id: pendingTrophyId, + reason: "reason", + declinedByUserId: 4, + }), + ).toBe(false); + + const pending = await TrophyRepository.findPendingById(pendingTrophyId); + expect(pending?.declinedAt).toBe(null); + expect(await trophyCount()).toBe(1); + }); + + test("approvals after a decline do not create a trophy", async () => { + await TrophyRepository.declinePending({ + id: pendingTrophyId, + reason: "reason", + declinedByUserId: 2, + }); + + await TrophyRepository.addApproval({ pendingTrophyId, userId: 2 }); + expect( + await TrophyRepository.addApproval({ pendingTrophyId, userId: 3 }), + ).toBe(null); + + expect(await trophyCount()).toBe(0); + }); +}); + +describe("trophy list tiers", () => { + let trophyId: number; + + beforeEach(async () => { + await dbInsertUsers(2); + + const trophy = await db + .insertInto("Trophy") + .values({ + name: "Tiered Trophy", + model: "model", + creatorId: 1, + managerId: 1, + }) + .returning("id") + .executeTakeFirstOrThrow(); + trophyId = trophy.id; + }); + + afterEach(() => dbReset()); + + test("an upcoming tournament without tier info does not hide the earned tier", async () => { + await insertTrophyTournament({ trophyId, tier: 3, startInDays: -21 }); + await insertTrophyTournament({ trophyId, tier: null, startInDays: 10 }); + + const trophy = (await TrophyRepository.all()).find( + (row) => row.name === "Tiered Trophy", + ); + + expect(trophy?.tier).toBe(3); + }); + + test("uses the most recent tier when multiple tournaments have one", async () => { + await insertTrophyTournament({ trophyId, tier: 5, startInDays: -30 }); + await insertTrophyTournament({ trophyId, tier: 3, startInDays: -7 }); + + const trophy = (await TrophyRepository.all()).find( + (row) => row.name === "Tiered Trophy", + ); + + expect(trophy?.tier).toBe(3); + }); + + test("has no tier when no linked tournament has tier info", async () => { + await insertTrophyTournament({ trophyId, tier: null, startInDays: 10 }); + + const trophy = (await TrophyRepository.all()).find( + (row) => row.name === "Tiered Trophy", + ); + + expect(trophy?.tier).toBe(null); + expect(trophy?.tentativeTier).toBe(null); + }); + + test("returns the start time of the next upcoming tournament", async () => { + await insertTrophyTournament({ trophyId, tier: 3, startInDays: -21 }); + await insertTrophyTournament({ trophyId, tier: null, startInDays: 20 }); + await insertTrophyTournament({ trophyId, tier: null, startInDays: 10 }); + + const trophy = (await TrophyRepository.all()).find( + (row) => row.name === "Tiered Trophy", + ); + + const expected = dateToDatabaseTimestamp( + new Date(Date.now() + 10 * 24 * 60 * 60 * 1000), + ); + expect( + Math.abs((trophy?.upcomingTournamentAt ?? 0) - expected), + ).toBeLessThan(10); + }); + + test("sorts trophies with an upcoming tournament first within the same tier", async () => { + await insertTrophyTournament({ trophyId, tier: 3, startInDays: -21 }); + + const upcoming = await db + .insertInto("Trophy") + .values({ + name: "Upcoming Trophy", + model: "model", + creatorId: 1, + managerId: 1, + }) + .returning("id") + .executeTakeFirstOrThrow(); + await insertTrophyTournament({ + trophyId: upcoming.id, + tier: 3, + startInDays: -14, + }); + await insertTrophyTournament({ + trophyId: upcoming.id, + tier: null, + startInDays: 10, + }); + + const distant = await db + .insertInto("Trophy") + .values({ + name: "Distant Trophy", + model: "model", + creatorId: 1, + managerId: 1, + }) + .returning("id") + .executeTakeFirstOrThrow(); + await insertTrophyTournament({ + trophyId: distant.id, + tier: 3, + startInDays: -7, + }); + await insertTrophyTournament({ + trophyId: distant.id, + tier: null, + startInDays: 5 * 7, + }); + + const names = (await TrophyRepository.all()).map((row) => row.name); + + expect(names).toEqual([ + "Upcoming Trophy", + "Tiered Trophy", + "Distant Trophy", + ]); + }); +}); + +async function trophyCount() { + const { count } = await db + .selectFrom("Trophy") + .select((eb) => eb.fn.countAll().as("count")) + .executeTakeFirstOrThrow(); + + return count; +} + +async function insertTrophyTournament({ + trophyId, + tier, + startInDays, +}: { + trophyId: number; + tier: TournamentTierNumber | null; + startInDays: number; +}) { + const tournament = await db + .insertInto("Tournament") + .values({ + mapPickingStyle: "AUTO_ALL", + settings: JSON.stringify({ bracketProgression: [] }), + tier, + }) + .returning("id") + .executeTakeFirstOrThrow(); + + const event = await db + .insertInto("CalendarEvent") + .values({ + name: `Tournament ${tournament.id}`, + bracketUrl: "https://example.com", + authorId: 1, + tournamentId: tournament.id, + trophyId, + }) + .returning("id") + .executeTakeFirstOrThrow(); + + await db + .insertInto("CalendarEventDate") + .values({ + eventId: event.id, + startsAt: dateToDatabaseTimestamp( + new Date(Date.now() + startInDays * 24 * 60 * 60 * 1000), + ), + }) + .execute(); +} diff --git a/app/features/trophies/TrophyRepository.server.ts b/app/features/trophies/TrophyRepository.server.ts new file mode 100644 index 000000000..e99bc2aa9 --- /dev/null +++ b/app/features/trophies/TrophyRepository.server.ts @@ -0,0 +1,883 @@ +import { sub } from "date-fns"; +import type { ExpressionBuilder, NotNull, Transaction } from "kysely"; +import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite"; +import * as R from "remeda"; +import { db } from "~/db/sql"; +import type { DB } from "~/db/tables"; +import { isSupporter } from "~/modules/permissions/utils"; +import { + databaseTimestampToDate, + dateToDatabaseTimestamp, +} from "~/utils/dates"; +import { + calendarEventStartTime, + commonUserSelect, + peakXpOverallSql, + tournamentLogoWithDefault, + tournamentTeamCount, +} from "~/utils/kysely.server"; +import { getTentativeTier } from "../tournament-organization/core/tentativeTiers.server"; +import { sortTrophiesByFavorites } from "../user-page/core/trophy-sorting.server"; +import { + SUPPORTER_TROPHY_CODE, + TROPHY_APPROVALS_REQUIRED, + XP_TROPHY_CODE_PREFIX, +} from "./trophies-constants"; +import { + hasUpcomingTournamentSoon, + parseSpecialTrophyCode, +} from "./trophies-utils"; + +type TrophyRecentTournament = { + tier: number | null; + name: string; + organizationId: number | null; + startTime: number | null; +}; + +export async function all() { + const rows = await db + .selectFrom("Trophy") + .select((eb) => ["id", "name", "model", withRecentTournaments(eb)]) + .execute(); + + return sortByEffectiveTier(rows.map(addEffectiveTier)); +} + +const withRecentTournaments = (eb: ExpressionBuilder) => + jsonArrayFrom( + eb + .selectFrom("CalendarEvent") + .innerJoin("Tournament", "Tournament.id", "CalendarEvent.tournamentId") + .select((eb2) => [ + "Tournament.tier", + "CalendarEvent.name", + "CalendarEvent.organizationId", + calendarEventStartTime(eb2).as("startTime"), + ]) + .whereRef("CalendarEvent.trophyId", "=", "Trophy.id") + .where("CalendarEvent.hidden", "=", 0) + .orderBy((eb2) => calendarEventStartTime(eb2), "desc"), + ).as("recentTournaments"); + +function addEffectiveTier< + T extends { recentTournaments: Array }, +>({ recentTournaments, ...rest }: T) { + const upcomingTournamentAt = nextUpcomingStartTime(recentTournaments); + + for (const tournament of recentTournaments) { + const tierInfo = tournamentTierInfo(tournament); + if (tierInfo.tier !== null || tierInfo.tentativeTier !== null) { + return { ...rest, ...tierInfo, upcomingTournamentAt }; + } + } + + return { ...rest, tier: null, tentativeTier: null, upcomingTournamentAt }; +} + +function nextUpcomingStartTime(tournaments: Array) { + const now = dateToDatabaseTimestamp(new Date()); + + // ordered newest first, so the last future start time is the next one up + const futureStartTimes = tournaments + .map((tournament) => tournament.startTime) + .filter( + (startTime): startTime is number => startTime !== null && startTime > now, + ); + + return futureStartTimes.at(-1) ?? null; +} + +function tournamentTierInfo(tournament: TrophyRecentTournament) { + const isPastEvent = + tournament.startTime !== null && + databaseTimestampToDate(tournament.startTime) < + sub(new Date(), { days: 1 }); + + const tentativeTier = + tournament.tier === null && + tournament.organizationId !== null && + !isPastEvent + ? getTentativeTier(tournament.organizationId, tournament.name) + : null; + + return { tier: tournament.tier, tentativeTier }; +} + +function sortByEffectiveTier< + T extends { + id: number; + tier: number | null; + tentativeTier: number | null; + upcomingTournamentAt: number | null; + }, +>(rows: T[]) { + return R.sortBy( + rows, + (row) => row.tier ?? row.tentativeTier ?? Number.MAX_SAFE_INTEGER, + (row) => (hasUpcomingTournamentSoon(row.upcomingTournamentAt) ? 0 : 1), + (row) => row.id, + ); +} + +const withCreator = (eb: ExpressionBuilder) => { + return jsonObjectFrom( + eb + .selectFrom("User") + .select((eb) => commonUserSelect(eb)) + .whereRef("User.id", "=", "Trophy.creatorId"), + ).as("creator"); +}; + +const withManager = (eb: ExpressionBuilder) => { + return jsonObjectFrom( + eb + .selectFrom("User") + .select((eb) => commonUserSelect(eb)) + .whereRef("User.id", "=", "Trophy.managerId"), + ).as("manager"); +}; + +const withOrganization = (eb: ExpressionBuilder) => { + return jsonObjectFrom( + eb + .selectFrom("TournamentOrganization") + .select(["TournamentOrganization.name", "TournamentOrganization.slug"]) + .whereRef("TournamentOrganization.id", "=", "Trophy.organizationId"), + ).as("organization"); +}; + +const withOwners = (eb: ExpressionBuilder) => { + return jsonArrayFrom( + eb + .selectFrom("TrophyOwner") + .innerJoin("User", "TrophyOwner.userId", "User.id") + .select((eb) => [ + eb.fn.count("TrophyOwner.trophyId").as("count"), + ...commonUserSelect(eb), + ]) + .whereRef("TrophyOwner.trophyId", "=", "Trophy.id") + .groupBy("User.id") + .orderBy("count", "desc"), + ).as("owners"); +}; + +const withSpecialOwners = (eb: ExpressionBuilder) => { + return jsonArrayFrom( + eb + .selectFrom("SpecialTrophyOwner") + .innerJoin("User", "SpecialTrophyOwner.userId", "User.id") + .select((eb) => [eb.val(1).as("count"), ...commonUserSelect(eb)]) + .whereRef("SpecialTrophyOwner.trophyId", "=", "Trophy.id") + .orderBy("User.id", "asc"), + ).as("specialOwners"); +}; + +export async function findByOrganizationId(organizationId: number) { + const rows = await db + .selectFrom("Trophy") + .select((eb) => ["id", "name", "model", withRecentTournaments(eb)]) + .where("organizationId", "=", organizationId) + .execute(); + + return sortByEffectiveTier(rows.map(addEffectiveTier)); +} + +export async function findByOrganizationIds(organizationIds: number[]) { + if (organizationIds.length === 0) return []; + + return db + .selectFrom("Trophy") + .select(["id", "name", "model", "organizationId"]) + .where("organizationId", "in", organizationIds) + .execute(); +} + +async function findOwnedTrophies(userId: number) { + const tournamentRows = await db + .selectFrom("TrophyOwner") + .innerJoin("Trophy", "Trophy.id", "TrophyOwner.trophyId") + .innerJoin("User", "User.id", "TrophyOwner.userId") + .select(({ fn }) => [ + fn.count("TrophyOwner.trophyId").as("count"), + fn.min("TrophyOwner.tier").as("tier"), + "Trophy.id", + "Trophy.name", + "Trophy.model", + "Trophy.code", + "User.favoriteTrophyIds", + "User.hiddenTrophyIds", + "User.patronTier", + ]) + .where("TrophyOwner.userId", "=", userId) + .groupBy(["TrophyOwner.trophyId", "TrophyOwner.userId"]) + .execute(); + + const specialRows = await db + .selectFrom("SpecialTrophyOwner") + .innerJoin("Trophy", "Trophy.id", "SpecialTrophyOwner.trophyId") + .innerJoin("User", "User.id", "SpecialTrophyOwner.userId") + .select([ + "Trophy.id", + "Trophy.name", + "Trophy.model", + "Trophy.code", + "User.favoriteTrophyIds", + "User.hiddenTrophyIds", + "User.patronTier", + ]) + .where("SpecialTrophyOwner.userId", "=", userId) + .execute(); + + return [ + ...tournamentRows, + ...specialRows.map((row) => ({ ...row, count: 1, tier: null })), + ]; +} + +export async function findByOwnerUserId(userId: number) { + const rows = await findOwnedTrophies(userId); + + if (rows.length === 0) return []; + + const { favoriteTrophyIds, hiddenTrophyIds, patronTier } = rows[0]; + const hiddenSet = new Set(hiddenTrophyIds ?? []); + + return sortTrophiesByFavorites({ + favoriteTrophyIds, + hiddenTrophyIds, + patronTier, + trophies: rows + .filter((row) => !hiddenSet.has(row.id)) + .map((row) => + R.omit(row, ["favoriteTrophyIds", "hiddenTrophyIds", "patronTier"]), + ), + }).trophies; +} + +export async function findByOwnerUserIdIncludingHidden(userId: number) { + const rows = await findOwnedTrophies(userId); + + return rows.map((row) => + R.omit(row, ["favoriteTrophyIds", "hiddenTrophyIds", "patronTier"]), + ); +} + +export async function findById(trophyId: number) { + const row = await db + .selectFrom("Trophy") + .select((eb) => [ + "Trophy.id", + "Trophy.name", + "Trophy.model", + "Trophy.code", + withCreator(eb), + withManager(eb), + withOrganization(eb), + withOwners(eb), + withSpecialOwners(eb), + ]) + .where("Trophy.id", "=", trophyId) + .executeTakeFirst(); + + if (!row) return null; + + const { specialOwners, ...trophy } = row; + + return { ...trophy, owners: [...trophy.owners, ...specialOwners] }; +} + +export async function findTournamentsByTrophyId(trophyId: number) { + const rows = await db + .selectFrom("CalendarEvent") + .innerJoin("Tournament", "Tournament.id", "CalendarEvent.tournamentId") + .select((eb) => [ + "Tournament.id as tournamentId", + "CalendarEvent.name", + "CalendarEvent.organizationId", + "Tournament.tier", + tournamentLogoWithDefault(eb).as("logoUrl"), + calendarEventStartTime(eb).as("startTime"), + tournamentTeamCount(eb).as("teamsCount"), + ]) + .where("CalendarEvent.trophyId", "=", trophyId) + .where("CalendarEvent.hidden", "=", 0) + .orderBy("startTime", "desc") + .execute(); + + return rows.map((row) => ({ + tournamentId: row.tournamentId, + name: row.name, + logoUrl: row.logoUrl, + startTime: row.startTime, + teamsCount: row.teamsCount, + ...tournamentTierInfo(row), + })); +} + +export async function findWinsByOwner({ + trophyId, + userId, +}: { + trophyId: number; + userId: number; +}) { + const tournaments = await db + .selectFrom("TrophyOwner") + .innerJoin("Tournament", "Tournament.id", "TrophyOwner.tournamentId") + .innerJoin("CalendarEvent", "CalendarEvent.tournamentId", "Tournament.id") + .select((eb) => [ + "TrophyOwner.tournamentId", + "TrophyOwner.tier", + "CalendarEvent.name", + tournamentLogoWithDefault(eb).as("logoUrl"), + calendarEventStartTime(eb).as("startTime"), + tournamentTeamCount(eb).as("teamsCount"), + ]) + .where("TrophyOwner.trophyId", "=", trophyId) + .where("TrophyOwner.userId", "=", userId) + .where("CalendarEvent.hidden", "=", 0) + .orderBy("startTime", "desc") + .execute(); + + if (tournaments.length === 0) return []; + + const tournamentIds = tournaments.map( + (tournament) => tournament.tournamentId, + ); + + const ownResults = await db + .selectFrom("TournamentResult") + .select(["tournamentId", "tournamentTeamId"]) + .where("userId", "=", userId) + .where("tournamentId", "in", tournamentIds) + .execute(); + + const teamIds = ownResults.map((result) => result.tournamentTeamId); + + const memberRows = + teamIds.length > 0 + ? await db + .selectFrom("TournamentResult") + .innerJoin("User", "User.id", "TournamentResult.userId") + .select((eb) => [ + "TournamentResult.tournamentTeamId", + "TournamentResult.setResults", + ...commonUserSelect(eb), + ]) + .where("TournamentResult.tournamentTeamId", "in", teamIds) + .execute() + : []; + + const memberIds = R.unique(memberRows.map((member) => member.id)); + + const weaponRows = + memberIds.length > 0 + ? await db + .selectFrom("ReportedWeapon") + .innerJoin( + "TournamentMatch", + "TournamentMatch.id", + "ReportedWeapon.tournamentMatchId", + ) + .innerJoin( + "TournamentStage", + "TournamentStage.id", + "TournamentMatch.stageId", + ) + .select(({ fn }) => [ + "TournamentStage.tournamentId", + "ReportedWeapon.userId", + "ReportedWeapon.weaponSplId", + fn.countAll().as("count"), + ]) + .where("TournamentStage.tournamentId", "in", tournamentIds) + .where("ReportedWeapon.userId", "in", memberIds) + .groupBy([ + "TournamentStage.tournamentId", + "ReportedWeapon.userId", + "ReportedWeapon.weaponSplId", + ]) + .orderBy("count", "desc") + .execute() + : []; + + return tournaments.map((tournament) => { + const ownResult = ownResults.find( + (result) => result.tournamentId === tournament.tournamentId, + ); + + const members = memberRows + .filter( + (member) => member.tournamentTeamId === ownResult?.tournamentTeamId, + ) + .map((member) => ({ + ...R.omit(member, ["tournamentTeamId"]), + weapons: weaponRows + .filter( + (weapon) => + weapon.tournamentId === tournament.tournamentId && + weapon.userId === member.id, + ) + .map((weapon) => weapon.weaponSplId), + })); + + return { + ...tournament, + members: R.sortBy( + members, + (member) => (member.id === userId ? 0 : 1), + (member) => member.username.toLowerCase(), + ), + }; + }); +} + +export async function findOrganizationIdById(trophyId: number) { + const row = await db + .selectFrom("Trophy") + .select("organizationId") + .where("id", "=", trophyId) + .executeTakeFirst(); + + return row?.organizationId ?? null; +} + +export async function existsByName(args: { + name: string; + excludeTrophyId?: number; +}) { + let trophyQuery = db + .selectFrom("Trophy") + .select("id") + .where("name", "=", args.name); + + if (args.excludeTrophyId !== undefined) { + trophyQuery = trophyQuery.where("id", "!=", args.excludeTrophyId); + } + + const trophy = await trophyQuery.executeTakeFirst(); + + if (trophy) return true; + + let pendingQuery = db + .selectFrom("PendingTrophy") + .select("id") + .where("name", "=", args.name) + .where("declinedAt", "is", null); + + if (args.excludeTrophyId !== undefined) { + pendingQuery = pendingQuery.where( + "targetTrophyId", + "is not", + args.excludeTrophyId, + ); + } + + const pending = await pendingQuery.executeTakeFirst(); + + return Boolean(pending); +} + +export async function findManagedBy(userId: number) { + return db + .selectFrom("Trophy") + .select(["id", "name", "model", "organizationId", "managerId"]) + .where("managerId", "=", userId) + .execute(); +} + +export async function findAllForEditing() { + return db + .selectFrom("Trophy") + .select(["id", "name", "model", "organizationId", "managerId"]) + .where("code", "is", null) + .execute(); +} + +/** + * Recomputes ownership of every special trophy (supporter, XP). + * Existing owner rows that are still eligible keep their original `createdAt`. + */ +export function syncSpecialTrophies() { + return db.transaction().execute(async (trx) => { + await syncSupporterTrophyOwners(trx); + await syncXpTrophyOwners(trx); + }); +} + +async function syncSupporterTrophyOwners(trx: Transaction) { + const trophy = await trx + .selectFrom("Trophy") + .select("id") + .where("code", "=", SUPPORTER_TROPHY_CODE) + .executeTakeFirst(); + + if (!trophy) return; + + const patrons = await trx + .selectFrom("User") + .select(["id", "patronTier"]) + .where("patronTier", "is not", null) + .execute(); + + await replaceSpecialTrophyOwners({ + trx, + trophyId: trophy.id, + userIds: patrons.filter(isSupporter).map((patron) => patron.id), + }); +} + +async function syncXpTrophyOwners(trx: Transaction) { + const xpTrophies = ( + await trx + .selectFrom("Trophy") + .select(["id", "code"]) + .where("code", "like", `${XP_TROPHY_CODE_PREFIX}%`) + .execute() + ).flatMap((trophy) => { + const parsed = parseSpecialTrophyCode(trophy.code); + return parsed?.type === "xp" + ? [{ id: trophy.id, value: parsed.value }] + : []; + }); + + if (xpTrophies.length === 0) return; + + const byValueDesc = R.sortBy(xpTrophies, [(trophy) => trophy.value, "desc"]); + + const userPeakXps = await trx + .selectFrom("SplatoonPlayer") + .select(["userId", peakXpOverallSql().as("peakXp")]) + .where("userId", "is not", null) + .where("peakXp", "is not", null) + .$narrowType<{ userId: NotNull; peakXp: NotNull }>() + .execute(); + + const ownersByTrophyId = new Map( + xpTrophies.map((trophy) => [trophy.id, []]), + ); + for (const { userId, peakXp } of userPeakXps) { + const highestReached = byValueDesc.find((trophy) => peakXp >= trophy.value); + if (!highestReached) continue; + + ownersByTrophyId.get(highestReached.id)?.push(userId); + } + + for (const [trophyId, userIds] of ownersByTrophyId) { + await replaceSpecialTrophyOwners({ trx, trophyId, userIds }); + } +} + +async function replaceSpecialTrophyOwners({ + trx, + trophyId, + userIds, +}: { + trx: Transaction; + trophyId: number; + userIds: number[]; +}) { + let deleteStale = trx + .deleteFrom("SpecialTrophyOwner") + .where("trophyId", "=", trophyId); + if (userIds.length > 0) { + deleteStale = deleteStale.where("userId", "not in", userIds); + } + await deleteStale.execute(); + + if (userIds.length === 0) return; + + await trx + .insertInto("SpecialTrophyOwner") + .values( + userIds.map((userId) => ({ + trophyId, + userId, + createdAt: dateToDatabaseTimestamp(new Date()), + })), + ) + .onConflict((oc) => oc.doNothing()) + .execute(); +} + +export async function createPending(args: { + name: string; + model: string; + description: string; + organizationId: number; + submitterUserId: number; + targetTrophyId?: number; + managerId?: number; +}) { + return db + .insertInto("PendingTrophy") + .values({ + name: args.name, + model: args.model, + description: args.description, + organizationId: args.organizationId, + submitterUserId: args.submitterUserId, + createdAt: dateToDatabaseTimestamp(new Date()), + declineReason: null, + declinedAt: null, + declinedByUserId: null, + targetTrophyId: args.targetTrophyId ?? null, + managerId: args.managerId ?? null, + }) + .returning("id") + .executeTakeFirstOrThrow(); +} + +const withApprovals = (eb: ExpressionBuilder) => { + return jsonArrayFrom( + eb + .selectFrom("PendingTrophyApproval") + .innerJoin("User", "PendingTrophyApproval.userId", "User.id") + .select([ + "PendingTrophyApproval.userId", + "PendingTrophyApproval.createdAt", + "User.username", + ]) + .whereRef( + "PendingTrophyApproval.pendingTrophyId", + "=", + "PendingTrophy.id", + ) + .orderBy("PendingTrophyApproval.createdAt", "asc"), + ).as("approvals"); +}; + +const withTarget = (eb: ExpressionBuilder) => { + return jsonObjectFrom( + eb + .selectFrom("Trophy") + .leftJoin("User", "User.id", "Trophy.managerId") + .leftJoin( + "TournamentOrganization", + "TournamentOrganization.id", + "Trophy.organizationId", + ) + .select([ + "Trophy.id", + "Trophy.name", + "Trophy.model", + "Trophy.organizationId", + "Trophy.managerId", + "User.username as managerUsername", + "TournamentOrganization.name as organizationName", + "TournamentOrganization.slug as organizationSlug", + ]) + .whereRef("Trophy.id", "=", "PendingTrophy.targetTrophyId"), + ).as("target"); +}; + +const withTargetManager = (eb: ExpressionBuilder) => { + return jsonObjectFrom( + eb + .selectFrom("User") + .select(["User.id", "User.username", "User.discordId"]) + .whereRef("User.id", "=", "PendingTrophy.managerId"), + ).as("manager"); +}; + +function pendingBaseQuery() { + return db + .selectFrom("PendingTrophy") + .leftJoin( + "User as Submitter", + "Submitter.id", + "PendingTrophy.submitterUserId", + ) + .leftJoin( + "User as Decliner", + "Decliner.id", + "PendingTrophy.declinedByUserId", + ) + .leftJoin( + "TournamentOrganization", + "TournamentOrganization.id", + "PendingTrophy.organizationId", + ) + .select((eb) => [ + "PendingTrophy.id", + "PendingTrophy.name", + "PendingTrophy.model", + "PendingTrophy.description", + "PendingTrophy.organizationId", + "PendingTrophy.submitterUserId", + "PendingTrophy.createdAt", + "PendingTrophy.declineReason", + "PendingTrophy.declinedAt", + "PendingTrophy.declinedByUserId", + "PendingTrophy.targetTrophyId", + "PendingTrophy.managerId", + "Submitter.username as submitterUsername", + "Submitter.discordId as submitterDiscordId", + "Decliner.username as declinedByUsername", + "TournamentOrganization.name as organizationName", + "TournamentOrganization.slug as organizationSlug", + withApprovals(eb), + withTarget(eb), + withTargetManager(eb), + ]); +} + +export async function findPendingById(id: number) { + const row = await pendingBaseQuery() + .where("PendingTrophy.id", "=", id) + .executeTakeFirst(); + + return row ?? null; +} + +export async function allPending() { + return pendingBaseQuery() + .orderBy("PendingTrophy.createdAt", "desc") + .execute(); +} + +export async function pendingBySubmitter(submitterUserId: number) { + return pendingBaseQuery() + .where("PendingTrophy.submitterUserId", "=", submitterUserId) + .orderBy("PendingTrophy.createdAt", "desc") + .execute(); +} + +export async function unreviewedCountBySubmitter(submitterUserId: number) { + const row = await db + .selectFrom("PendingTrophy") + .select((eb) => eb.fn.countAll().as("count")) + .where("submitterUserId", "=", submitterUserId) + .where("declinedAt", "is", null) + .where((eb) => + eb( + eb + .selectFrom("PendingTrophyApproval") + .select((eb2) => eb2.fn.countAll().as("approvalCount")) + .whereRef( + "PendingTrophyApproval.pendingTrophyId", + "=", + "PendingTrophy.id", + ), + "<", + TROPHY_APPROVALS_REQUIRED, + ), + ) + .executeTakeFirstOrThrow(); + + return row.count; +} + +export async function deletePending(id: number) { + await db.deleteFrom("PendingTrophy").where("id", "=", id).execute(); +} + +export async function declinePending(args: { + id: number; + reason: string; + declinedByUserId: number; +}) { + return db.transaction().execute(async (trx) => { + const { count } = await trx + .selectFrom("PendingTrophyApproval") + .select((eb) => eb.fn.countAll().as("count")) + .where("pendingTrophyId", "=", args.id) + .executeTakeFirstOrThrow(); + + if (count >= TROPHY_APPROVALS_REQUIRED) { + return false; + } + + await trx + .deleteFrom("PendingTrophyApproval") + .where("pendingTrophyId", "=", args.id) + .execute(); + + await trx + .updateTable("PendingTrophy") + .set({ + declineReason: args.reason, + declinedAt: dateToDatabaseTimestamp(new Date()), + declinedByUserId: args.declinedByUserId, + }) + .where("id", "=", args.id) + .execute(); + + return true; + }); +} + +export async function addApproval(args: { + pendingTrophyId: number; + userId: number; +}) { + return db.transaction().execute(async (trx) => { + const insertResult = await trx + .insertInto("PendingTrophyApproval") + .values({ + pendingTrophyId: args.pendingTrophyId, + userId: args.userId, + createdAt: dateToDatabaseTimestamp(new Date()), + }) + .onConflict((oc) => oc.doNothing()) + .executeTakeFirst(); + + if (!insertResult.numInsertedOrUpdatedRows) { + return null; + } + + const { count } = await trx + .selectFrom("PendingTrophyApproval") + .select((eb) => eb.fn.countAll().as("count")) + .where("pendingTrophyId", "=", args.pendingTrophyId) + .executeTakeFirstOrThrow(); + + if (count !== TROPHY_APPROVALS_REQUIRED) { + return null; + } + + const pending = await trx + .selectFrom("PendingTrophy") + .select([ + "id", + "name", + "model", + "organizationId", + "submitterUserId", + "targetTrophyId", + "managerId", + ]) + .where("id", "=", args.pendingTrophyId) + .where("declinedAt", "is", null) + .executeTakeFirst(); + + if (!pending) return null; + + if (pending.targetTrophyId !== null) { + await trx + .updateTable("Trophy") + .set({ + name: pending.name, + model: pending.model, + organizationId: pending.organizationId, + managerId: pending.managerId ?? pending.submitterUserId, + }) + .where("id", "=", pending.targetTrophyId) + .execute(); + return { id: pending.targetTrophyId }; + } + + return trx + .insertInto("Trophy") + .values({ + name: pending.name, + model: pending.model, + organizationId: pending.organizationId, + creatorId: pending.submitterUserId, + managerId: pending.managerId ?? pending.submitterUserId, + }) + .returning("id") + .executeTakeFirstOrThrow(); + }); +} diff --git a/app/features/trophies/actions/trophies.new.server.ts b/app/features/trophies/actions/trophies.new.server.ts new file mode 100644 index 000000000..521f82788 --- /dev/null +++ b/app/features/trophies/actions/trophies.new.server.ts @@ -0,0 +1,231 @@ +import type { ActionFunction } from "react-router"; +import { ADMIN_ID, QA_IDS } from "~/features/admin/admin-constants"; +import { + type AuthenticatedUser, + requireUser, +} from "~/features/auth/core/user.server"; +import { notify } from "~/features/notifications/core/notify.server"; +import { clearTrophiesCache } from "~/features/trophies/loaders/trophies.server"; +import * as UserRepository from "~/features/user-page/UserRepository.server"; +import { parseFormData } from "~/form/parse.server"; +import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server"; +import { assertUnreachable } from "~/utils/types"; +import * as TrophyRepository from "../TrophyRepository.server"; +import { + TROPHY_APPROVALS_REQUIRED, + TROPHY_PENDING_PER_USER_LIMIT, +} from "../trophies-constants"; +import { + pendingTrophyActionSchema, + trophyFormSchema, +} from "../trophies-schemas"; +import { + canAccessTrophies, + canEditTrophy, + canReviewTrophies, + compressTrophyModel, +} from "../trophies-utils"; + +export const action: ActionFunction = async ({ request }) => { + const user = requireUser(); + if (!canAccessTrophies(user)) { + throw new Response(null, { status: 404 }); + } + + const isJson = request.headers.get("Content-Type") === "application/json"; + + if (isJson) { + const result = await parseFormData({ + request, + schema: trophyFormSchema, + }); + + if (!result.success) { + return { fieldErrors: result.fieldErrors }; + } + + const data = result.data; + + const pendingCount = await TrophyRepository.unreviewedCountBySubmitter( + user.id, + ); + errorToastIfFalsy( + pendingCount < TROPHY_PENDING_PER_USER_LIMIT, + "Pending trophy limit reached", + ); + + if (data._action === "UPDATE") { + const trophy = await TrophyRepository.findById(data.targetTrophyId); + errorToastIfFalsy(trophy, "Trophy not found"); + errorToastIfFalsy( + canEditTrophy(user, { managerId: trophy.manager?.id ?? null }), + "Not allowed", + ); + + const nameExists = await TrophyRepository.existsByName({ + name: data.name, + excludeTrophyId: data.targetTrophyId, + }); + if (nameExists) { + return { fieldErrors: { name: "forms:errors.trophyNameTaken" } }; + } + + await TrophyRepository.createPending({ + name: data.name, + model: compressTrophyModel(data.model), + description: data.description ?? "", + organizationId: data.organizationId, + submitterUserId: user.id, + targetTrophyId: data.targetTrophyId, + managerId: data.managerId, + }); + + await notifyReviewersOfSubmission({ + trophyName: data.name, + submitter: user, + }); + + return null; + } + + const nameExists = await TrophyRepository.existsByName({ + name: data.name, + }); + if (nameExists) { + return { fieldErrors: { name: "forms:errors.trophyNameTaken" } }; + } + + await TrophyRepository.createPending({ + name: data.name, + model: compressTrophyModel(data.model), + description: data.description ?? "", + organizationId: data.organizationId, + submitterUserId: user.id, + }); + + await notifyReviewersOfSubmission({ + trophyName: data.name, + submitter: user, + }); + + return null; + } + + const data = await parseRequestPayload({ + request, + schema: pendingTrophyActionSchema, + }); + + switch (data._action) { + case "DELETE": { + const pending = await TrophyRepository.findPendingById( + data.pendingTrophyId, + ); + errorToastIfFalsy(pending, "Pending trophy not found"); + + const isOwner = pending.submitterUserId === user.id; + const canReview = canReviewTrophies(user); + errorToastIfFalsy(isOwner || canReview, "Not allowed"); + + await TrophyRepository.deletePending(data.pendingTrophyId); + return null; + } + case "DECLINE": { + errorToastIfFalsy(canReviewTrophies(user), "Not allowed"); + + const pending = await TrophyRepository.findPendingById( + data.pendingTrophyId, + ); + errorToastIfFalsy(pending, "Pending trophy not found"); + errorToastIfFalsy(!pending.declinedAt, "Trophy is already declined"); + errorToastIfFalsy( + pending.approvals.length < TROPHY_APPROVALS_REQUIRED, + "Cannot decline an accepted trophy", + ); + + const declined = await TrophyRepository.declinePending({ + id: data.pendingTrophyId, + reason: data.reason, + declinedByUserId: user.id, + }); + errorToastIfFalsy(declined, "Cannot decline an accepted trophy"); + + if (pending.submitterUserId !== user.id) { + notify({ + userIds: [pending.submitterUserId], + notification: { + type: "TROPHY_SUBMISSION_DECLINED", + meta: { trophyName: pending.name }, + }, + }); + } + + return null; + } + case "APPROVE": { + errorToastIfFalsy(canReviewTrophies(user), "Not allowed"); + + const pending = await TrophyRepository.findPendingById( + data.pendingTrophyId, + ); + + errorToastIfFalsy(pending, "Pending trophy not found"); + errorToastIfFalsy( + !pending.declinedAt, + "Cannot approve a declined trophy", + ); + errorToastIfFalsy( + pending.approvals.length < TROPHY_APPROVALS_REQUIRED, + "Trophy is already accepted", + ); + errorToastIfFalsy( + !pending.approvals.some((a) => a.userId === user.id), + "Already approved", + ); + + const inserted = await TrophyRepository.addApproval({ + pendingTrophyId: data.pendingTrophyId, + userId: user.id, + }); + + if (inserted) { + clearTrophiesCache(); + + if (pending.submitterUserId !== user.id) { + notify({ + userIds: [pending.submitterUserId], + notification: { + type: "TROPHY_SUBMISSION_ACCEPTED", + meta: { trophyName: pending.name, trophyId: inserted.id }, + }, + }); + } + } + + return null; + } + default: { + assertUnreachable(data); + } + } +}; + +async function notifyReviewersOfSubmission({ + trophyName, + submitter, +}: { + trophyName: string; + submitter: AuthenticatedUser; +}) { + const reviewerIds = await UserRepository.existingUserIds( + [ADMIN_ID, ...QA_IDS].filter((id) => id !== submitter.id), + ); + + notify({ + userIds: reviewerIds, + notification: { + type: "TROPHY_SUBMITTED", + meta: { trophyName, submitterUsername: submitter.username }, + }, + }); +} diff --git a/app/features/trophies/components/TournamentSummaryRow.module.css b/app/features/trophies/components/TournamentSummaryRow.module.css new file mode 100644 index 000000000..3f5708a44 --- /dev/null +++ b/app/features/trophies/components/TournamentSummaryRow.module.css @@ -0,0 +1,57 @@ +.row { + display: flex; + align-items: center; + gap: var(--s-2); + padding: var(--s-1-5); + border-radius: var(--radius-field); + + &:hover { + background-color: var(--color-bg-high); + } +} + +.logo { + border-radius: 50%; + flex-shrink: 0; +} + +.name { + font-size: var(--font-sm); + font-weight: var(--weight-semi); + display: flex; + align-items: center; + gap: 0 var(--s-2); + flex-wrap: wrap; +} + +.upcomingPill { + display: inline-flex; + align-items: center; + height: var(--selector-size-xs); + padding: 0 var(--s-1-5); + border-radius: var(--radius-selector); + background-color: var(--color-text-accent); + color: var(--color-text-inverse); + font-size: var(--font-2xs); + font-weight: var(--weight-bold); +} + +.meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--s-2); + color: var(--color-text-high); + font-size: var(--font-2xs); +} + +.metaItem { + display: flex; + align-items: center; + gap: var(--s-1); +} + +.metaIcon { + width: 12px; + height: 12px; +} diff --git a/app/features/trophies/components/TournamentSummaryRow.tsx b/app/features/trophies/components/TournamentSummaryRow.tsx new file mode 100644 index 000000000..250274d71 --- /dev/null +++ b/app/features/trophies/components/TournamentSummaryRow.tsx @@ -0,0 +1,99 @@ +import { clsx } from "clsx"; +import { Users } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; +import { TierPill } from "~/components/TierPill"; +import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; +import { useFormatDistanceToNow } from "~/hooks/intl/useFormatDistanceToNow"; +import { useHydrated } from "~/hooks/useHydrated"; +import { databaseTimestampToDate } from "~/utils/dates"; +import { tournamentPage } from "~/utils/urls"; +import styles from "./TournamentSummaryRow.module.css"; + +export function TournamentSummaryRow({ + tournament, + className, +}: { + tournament: { + tournamentId: number; + name: string; + logoUrl: string; + teamsCount: number | null; + tier?: number | null; + tentativeTier?: number | null; + startTime?: number | null; + }; + className?: string; +}) { + const { t } = useTranslation(["trophies"]); + const isHydrated = useHydrated(); + const formatDistanceToNow = useFormatDistanceToNow(); + const { formatter } = useDateTimeFormat({ + day: "numeric", + month: "short", + year: "numeric", + }); + + const isUpcoming = Boolean( + tournament.startTime && + databaseTimestampToDate(tournament.startTime) > new Date(), + ); + + return ( + + +
    + +

    {tournament.name}

    + {tournament.tier ? ( + + ) : tournament.tentativeTier ? ( + + ) : null} + {isUpcoming ? ( + + {t("trophies:details.upcoming")} + + ) : null} +
    +
    + + + {tournament.teamsCount} + + {tournament.startTime ? ( + isUpcoming ? ( + + ) : ( + + {formatter.format(tournament.startTime)} + + ) + ) : null} +
    +
    + + ); +} diff --git a/app/features/trophies/components/TrophiesSelector.module.css b/app/features/trophies/components/TrophiesSelector.module.css new file mode 100644 index 000000000..0031adcfb --- /dev/null +++ b/app/features/trophies/components/TrophiesSelector.module.css @@ -0,0 +1,54 @@ +.list { + display: flex; + flex-direction: column; + gap: var(--s-2); + padding: 0; + margin: 0; + list-style: none; +} + +.item { + display: flex; + align-items: center; + gap: var(--s-3); + padding: var(--s-1) var(--s-2); + border: var(--border-style); + border-radius: var(--radius-box); + background-color: var(--color-bg-high); + transition: opacity 0.2s; + + &.isDragging { + opacity: 0.5; + } +} + +.dragHandle { + cursor: grab; + user-select: none; + color: var(--color-text-high); + font-size: var(--font-md); + touch-action: none; + background: none; + border: none; + padding: 0; + flex-shrink: 0; + + &:active { + cursor: grabbing; + } +} + +.preview { + width: 40px; + flex-shrink: 0; +} + +.name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--font-sm); + font-weight: var(--weight-semi); +} diff --git a/app/features/trophies/components/TrophiesSelector.tsx b/app/features/trophies/components/TrophiesSelector.tsx new file mode 100644 index 000000000..e12354f1d --- /dev/null +++ b/app/features/trophies/components/TrophiesSelector.tsx @@ -0,0 +1,177 @@ +import type { DragEndEvent } from "@dnd-kit/core"; +import { + closestCenter, + DndContext, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import clsx from "clsx"; +import { Trash } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { SendouButton } from "~/components/elements/Button"; +import styles from "./TrophiesSelector.module.css"; +import { Trophy, TrophyContextProvider } from "./Trophy"; +import type { TrophyDisplayProps } from "./TrophyDisplay"; + +type TrophyItem = TrophyDisplayProps["trophies"][number]; + +export function TrophiesSelector({ + options, + selectedTrophies, + onChange, + onBlur, + maxCount, +}: { + options: TrophyDisplayProps["trophies"]; + selectedTrophies: number[]; + onChange: (newTrophies: number[]) => void; + onBlur?: () => void; + maxCount?: number; +}) { + const { t } = useTranslation(["common"]); + + const selectedTrophiesInOrder = selectedTrophies + .map((id) => options.find((trophy) => trophy.id === id)) + .filter((trophy): trophy is TrophyItem => trophy !== undefined); + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const handleRemove = (trophyId: number) => { + onChange(selectedTrophies.filter((id) => id !== trophyId)); + }; + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + + const oldIndex = selectedTrophies.indexOf(active.id as number); + const newIndex = selectedTrophies.indexOf(over.id as number); + if (oldIndex === -1 || newIndex === -1) return; + + const newOrder = [...selectedTrophies]; + const [removed] = newOrder.splice(oldIndex, 1); + newOrder.splice(newIndex, 0, removed); + onChange(newOrder); + }; + + return ( +
    + {selectedTrophiesInOrder.length > 0 ? ( + + + trophy.id)} + strategy={verticalListSortingStrategy} + > +
      + {selectedTrophiesInOrder.map((trophy) => ( + + ))} +
    +
    +
    +
    + ) : ( +
    + {t("common:trophies.selector.none")} +
    + )} + {options.length === 0 ? ( +
    + {t("common:trophies.selector.noneAvailable")} +
    + ) : ( + + )} +
    + ); +} + +function SortableTrophyItem({ + trophy, + onRemove, +}: { + trophy: TrophyItem; + onRemove: (id: number) => void; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: trophy.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
  • + +
    + +
    + {trophy.name} + } + aria-label="Remove" + onPress={() => onRemove(trophy.id)} + /> +
  • + ); +} diff --git a/app/features/trophies/components/Trophy.module.css b/app/features/trophies/components/Trophy.module.css new file mode 100644 index 000000000..0dfd2a289 --- /dev/null +++ b/app/features/trophies/components/Trophy.module.css @@ -0,0 +1,73 @@ +.container { + position: relative; +} + +.tile { + border: var(--border-width) solid var(--tier-bg); + border-radius: var(--radius-field); + background-color: var(--color-bg-high); +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, 100px); + justify-content: center; + gap: var(--s-2); +} + +.placeholder { + width: 100%; + aspect-ratio: 1 / 1; +} + +.tierPill { + position: absolute; + top: -1px; + right: -1px; + + > * { + border-radius: 0 var(--radius-field) 0 var(--radius-field); + } +} + +.cornerPill { + position: absolute; + bottom: -1px; + left: -1px; + display: flex; + align-items: center; + height: var(--selector-size-sm); + padding: 0 var(--s-1-5); + border-radius: 0 var(--radius-field) 0 var(--radius-field); + background-color: var(--tier-bg, var(--color-bg-higher)); + color: var(--tier-text, var(--color-text-high)); + font-size: var(--font-2xs); + font-weight: var(--weight-bold); +} + +.trophy { + width: 100% !important; + height: auto !important; + aspect-ratio: 1 / 1; + opacity: 1; + transition: opacity 0.2s ease; + + @starting-style { + opacity: 0; + } +} + +.interactive { + cursor: grab; + + &:active { + cursor: grabbing; + } +} + +.error { + color: var(--color-text-high); + display: flex; + align-items: center; + justify-content: center; +} diff --git a/app/features/trophies/components/Trophy.tsx b/app/features/trophies/components/Trophy.tsx new file mode 100644 index 000000000..e8116479b --- /dev/null +++ b/app/features/trophies/components/Trophy.tsx @@ -0,0 +1,255 @@ +import { clsx } from "clsx"; +import { Ban } from "lucide-react"; +import { PicoCAD2Context, PicoCAD2Viewer } from "picocad2-web"; +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import { TierPill } from "~/components/TierPill"; +import { IS_E2E_TEST_RUN } from "~/utils/e2e"; +import { decompressTrophyModel } from "../trophies-utils"; +import style from "./Trophy.module.css"; + +type TrophyCtxValue = + | { context: PicoCAD2Context } + | { context: undefined; isLoading: true }; + +const TrophyCtx = createContext(undefined); + +/** + * Shares one PicoCAD2 WebGL context across every `Trophy` rendered inside. + * + * `Trophy` falls back to creating its own internal context when no provider is + * used, but browsers cap active WebGL contexts at 16 so any grid bigger than + * that, or rapid mounting/unmounting, triggers a warning and broken rendering. + * + * We use two implementations to stay under the limit: + * + * 1. One page wide `PicoCAD2Context` singleton. + * Creating a new one per mount can stack contexts faster than the browser + * can remove them. Holding one for the page lifetime is cheaper in comparison. + * + * 2. The provider always renders its children so surrounding layout doesn't shift + * but uses an additional `isLoading` bool while `useState` is still `undefined` + * before the first effect fires. Descendant `Trophy` components read the bool + * and render an empty spacer instead of mounting a canvas that would create + * their own internal context. + */ + +let sharedContext: PicoCAD2Context | undefined; + +function getSharedTrophyContext() { + if (typeof window === "undefined") return undefined; + if (!sharedContext) sharedContext = new PicoCAD2Context(); + return sharedContext; +} + +export function TrophyContextProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [context, setContext] = useState(); + + useEffect(() => { + setContext(getSharedTrophyContext()); + }, []); + + const value: TrophyCtxValue = context + ? { context } + : { context: undefined, isLoading: true }; + + return {children}; +} + +export function TrophyGrid({ children }: { children: React.ReactNode }) { + return
    {children}
    ; +} + +export function TrophyPlaceholder() { + return
    ; +} + +export function Trophy({ + model, + className, + preview, + tile, + tier, + tentativeTier, + disableCameraControls, + staticOnSoftwareRendering, + pill, +}: { + model: string; + className?: string; + preview?: boolean; + tile?: boolean; + tier?: number | null; + tentativeTier?: number | null; + disableCameraControls?: boolean; + staticOnSoftwareRendering?: boolean; + pill?: React.ReactNode; +}) { + const ctxValue = useContext(TrophyCtx); + const context = ctxValue?.context; + const isLoadingSharedContext = + ctxValue !== undefined && ctxValue.context === undefined; + const viewerRef = useRef(null); + const [error, setError] = useState(false); + + const prevModelRef = useRef(model); + if (prevModelRef.current !== model) { + prevModelRef.current = model; + setError(false); + } + + const modelState = decompressTrophyModel(model); + + // useCallback is needed to keep the ref callback identity stable. + // A new identity each render would make React detach and re-attach, + // disposing and rebuilding the viewer on every re-render + const canvasRef = useCallback( + (canvas: HTMLCanvasElement | null) => { + if (!canvas) { + viewerRef.current?.dispose(); + viewerRef.current = null; + return; + } + + if (modelState === null) return; + + const viewer = new PicoCAD2Viewer({ + canvas, + context, + resolution: { width: 128, height: 128, scale: 4 }, + }); + viewerRef.current = viewer; + + try { + viewer.setState(JSON.parse(modelState)); + } catch (_) { + setError(true); + return; + } + + // continuous render loops starve the main thread when WebGL is + // software rendered, so e2e runs (which always render on CPU) draw a + // single static frame, as do surfaces showing many loops at once on + // devices without GPU acceleration + if ( + preview || + IS_E2E_TEST_RUN || + (staticOnSoftwareRendering && isSoftwareRendering()) + ) { + viewer.draw(); + viewer.dispose(); + return; + } + + viewer.cameraMode = "spin"; + viewer.cameraModeSpeed = 5; + viewer.startRenderLoop(false); + + if (disableCameraControls) return; + + viewer.enableCameraControls({ + spinInertiaFactor: 0.95, + pan: false, + rotate: true, + zoom: true, + useFixedOnInteract: { + enabled: true, + delayBeforeRestore: 1000, + restoreTime: 1000, + }, + }); + }, + [ + modelState, + context, + preview, + staticOnSoftwareRendering, + disableCameraControls, + ], + ); + + const effectiveTier = tier ?? tentativeTier ?? null; + const containerClassName = clsx(style.container, className, { + [style.tile]: tile, + }); + const containerStyle = effectiveTier + ? ({ + "--tier-bg": `var(--tier-bg-${effectiveTier})`, + "--tier-text": `var(--tier-text-${effectiveTier})`, + } as React.CSSProperties) + : undefined; + + const tierPill = tier ? ( +
    + +
    + ) : tentativeTier ? ( +
    + +
    + ) : null; + + const cornerPill = pill ? ( +
    + {pill} +
    + ) : null; + + if (error || modelState === null) { + return ( +
    +
    + +
    + {tierPill} + {cornerPill} +
    + ); + } + + if (isLoadingSharedContext) { + return ( +
    +
    +
    + ); + } + + return ( +
    + + {tierPill} + {cornerPill} +
    + ); +} + +let softwareRenderingDetected: boolean | undefined; + +function isSoftwareRendering() { + if (softwareRenderingDetected !== undefined) return softwareRenderingDetected; + if (typeof document === "undefined") return false; + + const gl = document + .createElement("canvas") + .getContext("webgl2", { failIfMajorPerformanceCaveat: true }); + softwareRenderingDetected = !gl; + gl?.getExtension("WEBGL_lose_context")?.loseContext(); + + return softwareRenderingDetected; +} diff --git a/app/features/trophies/components/TrophyDisplay.module.css b/app/features/trophies/components/TrophyDisplay.module.css new file mode 100644 index 000000000..b8109d8bb --- /dev/null +++ b/app/features/trophies/components/TrophyDisplay.module.css @@ -0,0 +1,112 @@ +.root { + display: flex; + flex-direction: column; + margin-inline: auto; + width: max-content; +} + +.grid { + display: grid; + grid-template-columns: repeat(3, 100px); + justify-content: center; + gap: var(--s-2); + padding: var(--s-2); + border: var(--border-style); + border-radius: var(--radius-box); +} + +.trophyButton { + all: unset; + cursor: pointer; + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.win { + display: flex; + flex-direction: column; + gap: var(--s-1-5); + opacity: 1; + transition: opacity 0.2s ease; + + @starting-style { + opacity: 0; + } +} + +.winHeader { + margin-top: var(--s-2); +} + +.winDetails { + display: flex; + flex-direction: column; + gap: var(--s-2); + padding-inline: var(--s-2); +} + +.winMembers { + display: flex; + flex-direction: column; +} + +.winMember { + display: grid; + grid-template-columns: 100px 1fr max-content; + align-items: center; + gap: var(--s-2); + font-size: var(--font-xs); + border-radius: var(--radius-field); + padding: var(--s-1); + + & > * { + min-width: 0; + max-width: 100%; + } + + &:nth-child(odd) { + background-color: var(--color-bg-high); + } +} + +@container (width >= 560px) and (width <= 750px) { + .winMember { + grid-template-columns: 1fr 1fr; + grid-template-rows: auto auto; + justify-items: center; + + & > button { + grid-column: 1 / -1; + } + } +} + +.winMemberUser { + display: flex; + align-items: center; + gap: var(--s-1-5); + font-weight: var(--weight-semi); + min-width: 0; +} + +.winMemberName { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.winMemberWeapons { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: var(--s-1); +} + +.specialDescription { + font-size: var(--font-sm); + color: var(--color-text-high); + padding-top: var(--s-2); +} diff --git a/app/features/trophies/components/TrophyDisplay.tsx b/app/features/trophies/components/TrophyDisplay.tsx new file mode 100644 index 000000000..4efcf3b5a --- /dev/null +++ b/app/features/trophies/components/TrophyDisplay.tsx @@ -0,0 +1,211 @@ +import clsx from "clsx"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { useFetcher } from "react-router"; +import { Avatar } from "~/components/Avatar"; +import { Divider } from "~/components/Divider"; +import { DotPagination } from "~/components/DotPagination"; +import { WeaponImage } from "~/components/Image"; +import { UserCard } from "~/features/user-card/components/UserCard"; +import { ParticipationPill } from "~/features/user-page/components/ParticipationPill"; +import { usePagination } from "~/hooks/usePagination"; +import { trophyWinsPage } from "~/utils/urls"; +import type { TrophyWinsLoaderData } from "../routes/trophies.$id.wins.$userId"; +import { SMALL_TROPHIES_PER_DISPLAY_PAGE } from "../trophies-constants"; +import { + parseSpecialTrophyCode, + useProgressiveRender, +} from "../trophies-utils"; +import { TournamentSummaryRow } from "./TournamentSummaryRow"; +import { Trophy, TrophyContextProvider, TrophyPlaceholder } from "./Trophy"; +import styles from "./TrophyDisplay.module.css"; +import { TrophyShowcaseModal } from "./TrophyShowcase"; + +type TrophyItem = { + id: number; + name: string; + model: string; + tier?: number | null; + code?: string | null; + count?: number | null; +}; + +export interface TrophyDisplayProps { + trophies: Array; + userId: number; + className?: string; +} + +export function TrophyDisplay({ + trophies, + userId, + className, +}: TrophyDisplayProps) { + const [openTrophy, setOpenTrophy] = React.useState(null); + + const { + itemsToDisplay, + everythingVisible, + currentPage, + pagesCount, + setPage, + } = usePagination({ + items: trophies, + pageSize: SMALL_TROPHIES_PER_DISPLAY_PAGE, + scrollToTop: false, + }); + + const visibleCount = useProgressiveRender( + itemsToDisplay.length, + String(currentPage), + ); + + if (trophies.length === 0) return null; + + return ( + +
    +
    + {itemsToDisplay.map((trophy, i) => + i < visibleCount ? ( + + ) : ( + + ), + )} +
    + {!everythingVisible ? ( + + ) : null} +
    + {openTrophy ? ( + setOpenTrophy(null)} + /> + ) : null} +
    + ); +} + +function TrophyModal({ + trophy, + userId, + onClose, +}: { + trophy: TrophyItem; + userId: number; + onClose: () => void; +}) { + const { t } = useTranslation(["trophies"]); + const fetcher = useFetcher(); + const data = fetcher.data; + + const special = parseSpecialTrophyCode(trophy.code); + + const loadedRef = React.useRef(false); + React.useEffect(() => { + if (parseSpecialTrophyCode(trophy.code) || loadedRef.current) return; + loadedRef.current = true; + fetcher.load(trophyWinsPage({ trophyId: trophy.id, userId })); + }, [fetcher.load, trophy.id, trophy.code, userId]); + + return ( + + {special ? ( +
    + +

    + {special.type === "supporter" + ? t("trophies:special.supporter.description") + : t("trophies:special.xp.description", { + value: special.value, + })} +

    +
    + ) : null} + {data + ? data.wins.map((win) => ( +
    + + +
    + )) + : null} +
    + ); +} + +function TrophyWinDetails({ + win, + userCards, +}: { + win: TrophyWinsLoaderData["wins"][number]; + userCards: TrophyWinsLoaderData["userCards"]; +}) { + return ( +
    + +
    + {win.members.length > 0 ? ( +
    + {win.members.map((member) => ( +
    + + + + + {member.username} + + + + + {member.weapons.map((weaponSplId) => ( + + ))} + + +
    + ))} +
    + ) : null} +
    +
    + ); +} diff --git a/app/features/trophies/components/TrophyShowcase.module.css b/app/features/trophies/components/TrophyShowcase.module.css new file mode 100644 index 000000000..271384493 --- /dev/null +++ b/app/features/trophies/components/TrophyShowcase.module.css @@ -0,0 +1,47 @@ +.wrapper { + container-type: inline-size; +} + +.modal { + padding: 0; + max-width: 48rem; +} + +.trophyName { + font-weight: var(--weight-bold); + font-size: var(--font-lg); +} + +.trophyPageLink { + font-size: var(--font-2xs); + margin-bottom: var(--s-2); +} + +.content { + display: grid; + grid-template-columns: 1fr; + align-items: center; + background-color: var(--color-bg-high); +} + +.details { + padding: var(--s-4); + display: flex; + flex-direction: column; + gap: var(--s-2); + background-color: var(--color-bg); + border-top: var(--border-style); + overflow-y: auto; +} + +@container (width >= 560px) { + .content { + grid-template-columns: 60% 1fr; + } + + .details { + border-top: 0; + border-left: var(--border-style); + height: 60cqw; + } +} diff --git a/app/features/trophies/components/TrophyShowcase.tsx b/app/features/trophies/components/TrophyShowcase.tsx new file mode 100644 index 000000000..8674af517 --- /dev/null +++ b/app/features/trophies/components/TrophyShowcase.tsx @@ -0,0 +1,63 @@ +import clsx from "clsx"; +import type * as React from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; +import { SendouDialog } from "~/components/elements/Dialog"; +import { trophyPage } from "~/utils/urls"; +import { Trophy } from "./Trophy"; +import styles from "./TrophyShowcase.module.css"; + +export function TrophyShowcase({ + model, + children, + className, + detailsClassName, +}: { + model: string; + children: React.ReactNode; + className?: string; + detailsClassName?: string; +}) { + return ( +
    +
    + +
    + {children} +
    +
    +
    + ); +} + +export function TrophyShowcaseModal({ + trophy, + onClose, + children, +}: { + trophy: { id: number; name: string; model: string }; + onClose: () => void; + children?: React.ReactNode; +}) { + const { t } = useTranslation(["trophies"]); + + return ( + + +
    +

    {trophy.name}

    + + {t("trophies:display.viewTrophyPage")} + +
    + {children} +
    +
    + ); +} diff --git a/app/features/trophies/components/TrophyTournamentHistory.module.css b/app/features/trophies/components/TrophyTournamentHistory.module.css new file mode 100644 index 000000000..bc29f5488 --- /dev/null +++ b/app/features/trophies/components/TrophyTournamentHistory.module.css @@ -0,0 +1,11 @@ +.list { + display: flex; + flex-direction: column; + padding: 0; + margin: 0; + gap: var(--s-1); + + & > li { + list-style: none; + } +} diff --git a/app/features/trophies/components/TrophyTournamentHistory.tsx b/app/features/trophies/components/TrophyTournamentHistory.tsx new file mode 100644 index 000000000..71924cfca --- /dev/null +++ b/app/features/trophies/components/TrophyTournamentHistory.tsx @@ -0,0 +1,19 @@ +import type { TrophyTournamentsLoaderData } from "../routes/trophies.$id.tournaments"; +import { TournamentSummaryRow } from "./TournamentSummaryRow"; +import styles from "./TrophyTournamentHistory.module.css"; + +export function TrophyTournamentHistory({ + tournaments, +}: { + tournaments: TrophyTournamentsLoaderData["tournaments"]; +}) { + return ( +
      + {tournaments.map((tournament) => ( +
    • + +
    • + ))} +
    + ); +} diff --git a/app/features/trophies/core/model-analysis.ts b/app/features/trophies/core/model-analysis.ts new file mode 100644 index 000000000..83eca936e --- /dev/null +++ b/app/features/trophies/core/model-analysis.ts @@ -0,0 +1,110 @@ +import type { Color3, ExtrasOptions, ViewerSettings } from "picocad2-web"; + +export interface TrophyModelAnalysis { + cameraTargetCentered: boolean; + backgroundIsAlpha: boolean; + drawCalls: number; + polyCount: number; + effectsCount: number; +} + +type RawColor = Array | { r: number; g: number; b: number }; + +interface RawFace { + vertex_ids: Array; + dbl?: boolean; + prio?: boolean; +} + +interface RawGraphNode { + visible: boolean; + children: Array; + mesh?: { faces: Array }; +} + +interface ModelState { + source: { + graph: RawGraphNode; + texture: { + colors: Array; + background_color: number; + transparent_color: number; + }; + }; + settings: ViewerSettings; + extras?: ExtrasOptions; +} + +export function analyzeTrophyModel(model: string): TrophyModelAnalysis | null { + try { + const state: ModelState = JSON.parse(model); + + return { + cameraTargetCentered: isCameraTargetCentered(state), + backgroundIsAlpha: isBackgroundAlpha(state), + ...countGeometry(state), + effectsCount: countEnabledEffects(state), + }; + } catch { + return null; + } +} + +function isCameraTargetCentered(state: ModelState) { + const target = state.settings.camera.target; + return target[0] === 0 && target[2] === 0; +} + +function isBackgroundAlpha(state: ModelState) { + const texture = state.source.texture; + const transparent = toColor3(texture.colors[texture.transparent_color]); + const background = + state.settings.backgroundColor ?? + toColor3(texture.colors[texture.background_color]); + + return ( + background[0] === transparent[0] && + background[1] === transparent[1] && + background[2] === transparent[2] + ); +} + +function toColor3(color: RawColor): Color3 { + return Array.isArray(color) + ? [color[0], color[1], color[2]] + : [color.r, color.g, color.b]; +} + +function countGeometry(state: ModelState) { + const counts = { drawCalls: 0, polyCount: 0 }; + + const visit = (node: RawGraphNode, parentVisible: boolean) => { + for (const child of node.children) { + const visible = Boolean(child.visible) && parentVisible; + + if (visible && child.mesh) { + const groups = new Set(); + + for (const face of child.mesh.faces) { + if (face.vertex_ids.length < 3) continue; + + groups.add((face.dbl ? 1 : 0) | (face.prio ? 2 : 0)); + counts.polyCount += face.vertex_ids.length - 2; + } + + counts.drawCalls += groups.size; + } + + visit(child, visible); + } + }; + visit(state.source.graph, true); + + return counts; +} + +function countEnabledEffects(state: ModelState) { + return Object.values(state.extras ?? {}).filter( + (effect) => effect?.enabled === true, + ).length; +} diff --git a/app/features/trophies/loaders/trophies.$id.server.ts b/app/features/trophies/loaders/trophies.$id.server.ts new file mode 100644 index 000000000..59dd2e2c5 --- /dev/null +++ b/app/features/trophies/loaders/trophies.$id.server.ts @@ -0,0 +1,27 @@ +import type { LoaderFunctionArgs } from "react-router"; +import { getUser } from "~/features/auth/core/user.server"; +import { notFoundIfNullish, parseParams } from "~/utils/remix.server"; +import { idObject } from "~/utils/zod"; +import * as TrophyRepository from "../TrophyRepository.server"; +import { canAccessTrophies } from "../trophies-utils"; + +export const loader = async ({ params }: LoaderFunctionArgs) => { + if (!canAccessTrophies(getUser())) { + throw new Response(null, { status: 404 }); + } + + const { id } = parseParams({ + params, + schema: idObject, + }); + + const [trophy, tournaments] = await Promise.all([ + TrophyRepository.findById(id).then(notFoundIfNullish), + TrophyRepository.findTournamentsByTrophyId(id), + ]); + + return { + trophy, + tournaments, + }; +}; diff --git a/app/features/trophies/loaders/trophies.new.server.ts b/app/features/trophies/loaders/trophies.new.server.ts new file mode 100644 index 000000000..59d8f5809 --- /dev/null +++ b/app/features/trophies/loaders/trophies.new.server.ts @@ -0,0 +1,67 @@ +import type { LoaderFunctionArgs } from "react-router"; +import { requireUser } from "~/features/auth/core/user.server"; +import type { SerializeFrom } from "~/utils/remix"; +import * as TrophyRepository from "../TrophyRepository.server"; +import { TROPHY_APPROVALS_REQUIRED } from "../trophies-constants"; +import { + canAccessTrophies, + canEditAnyTrophy, + canReviewTrophies, +} from "../trophies-utils"; + +export type NewTrophyLoaderData = SerializeFrom; + +export const loader = async (_args: LoaderFunctionArgs) => { + const user = requireUser(); + if (!canAccessTrophies(user)) { + throw new Response(null, { status: 404 }); + } + + const canReview = canReviewTrophies(user); + + const [rawItems, ownUnreviewedCount, editableTrophies] = await Promise.all([ + canReview + ? TrophyRepository.allPending() + : TrophyRepository.pendingBySubmitter(user.id), + TrophyRepository.unreviewedCountBySubmitter(user.id), + canEditAnyTrophy(user) + ? TrophyRepository.findAllForEditing() + : TrophyRepository.findManagedBy(user.id), + ]); + + const allItems = canReview ? rawItems : rawItems.map(stripReviewerInfo); + + const isAccepted = (item: (typeof allItems)[number]) => + item.approvals.length >= TROPHY_APPROVALS_REQUIRED; + + const pendingTrophies = allItems.filter( + (item) => !isAccepted(item) && !item.declinedAt, + ); + const reviewedTrophies = allItems.filter( + (item) => isAccepted(item) || item.declinedAt, + ); + + return { + canReview, + currentUserId: user.id, + ownUnreviewedCount, + pendingTrophies, + reviewedTrophies, + editableTrophies, + }; +}; + +function stripReviewerInfo( + item: Awaited>[number], +) { + return { + ...item, + approvals: item.approvals.map(() => ({ + userId: null as number | null, + username: null as string | null, + createdAt: 0, + })), + declinedByUserId: null, + declinedByUsername: null, + }; +} diff --git a/app/features/trophies/loaders/trophies.server.ts b/app/features/trophies/loaders/trophies.server.ts new file mode 100644 index 000000000..34f94d587 --- /dev/null +++ b/app/features/trophies/loaders/trophies.server.ts @@ -0,0 +1,28 @@ +import { cachified } from "@epic-web/cachified"; +import { getUser } from "~/features/auth/core/user.server"; +import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server"; +import * as TrophyRepository from "../TrophyRepository.server"; +import { canAccessTrophies } from "../trophies-utils"; + +const TROPHIES_CACHE_KEY = "trophies"; + +export const loader = async () => { + if (!canAccessTrophies(getUser())) { + throw new Response(null, { status: 404 }); + } + + const trophies = await cachified({ + key: TROPHIES_CACHE_KEY, + cache, + ttl: ttl(IN_MILLISECONDS.TWO_HOURS), + async getFreshValue() { + return TrophyRepository.all(); + }, + }); + + return { trophies }; +}; + +export function clearTrophiesCache() { + cache.delete(TROPHIES_CACHE_KEY); +} diff --git a/app/features/trophies/routes/trophies.$id.tournaments.ts b/app/features/trophies/routes/trophies.$id.tournaments.ts new file mode 100644 index 000000000..f668fd515 --- /dev/null +++ b/app/features/trophies/routes/trophies.$id.tournaments.ts @@ -0,0 +1,24 @@ +import type { LoaderFunctionArgs } from "react-router"; +import { getUser } from "~/features/auth/core/user.server"; +import type { SerializeFrom } from "~/utils/remix"; +import { parseParams } from "~/utils/remix.server"; +import { idObject } from "~/utils/zod"; +import * as TrophyRepository from "../TrophyRepository.server"; +import { canAccessTrophies } from "../trophies-utils"; + +export type TrophyTournamentsLoaderData = SerializeFrom; + +export const loader = async ({ params }: LoaderFunctionArgs) => { + if (!canAccessTrophies(getUser())) { + throw new Response(null, { status: 404 }); + } + + const { id } = parseParams({ + params, + schema: idObject, + }); + + return { + tournaments: await TrophyRepository.findTournamentsByTrophyId(id), + }; +}; diff --git a/app/features/trophies/routes/trophies.$id.tsx b/app/features/trophies/routes/trophies.$id.tsx new file mode 100644 index 000000000..364431e0b --- /dev/null +++ b/app/features/trophies/routes/trophies.$id.tsx @@ -0,0 +1,145 @@ +import clsx from "clsx"; +import { Trans, useTranslation } from "react-i18next"; +import { Link, type MetaFunction, useLoaderData } from "react-router"; +import { Divider } from "~/components/Divider"; +import { metaTags, type SerializeFrom } from "~/utils/remix"; +import { tournamentOrganizationPage, userPage } from "~/utils/urls"; +import { TrophyShowcase } from "../components/TrophyShowcase"; +import { TrophyTournamentHistory } from "../components/TrophyTournamentHistory"; +import { loader } from "../loaders/trophies.$id.server"; +import { parseSpecialTrophyCode } from "../trophies-utils"; +import styles from "./trophies.module.css"; + +export { loader }; + +export const meta: MetaFunction = (args) => { + const data = args.loaderData as SerializeFrom | null; + + if (!data) return []; + + const ownerCount = data.trophy.owners.reduce( + (sum, owner) => sum + owner.count, + 0, + ); + + return metaTags({ + title: data.trophy.name, + ogTitle: `${data.trophy.name} (Splatoon trophy)`, + description: `See who owns the ${data.trophy.name} trophy on sendou.ink. Awarded ${ownerCount} time${ownerCount === 1 ? "" : "s"} so far.`, + location: args.location, + }); +}; + +export default function TrophyDetailsPage() { + const { t } = useTranslation(["trophies"]); + const data = useLoaderData(); + const { trophy, tournaments } = data; + + const special = parseSpecialTrophyCode(trophy.code); + + return ( + +
    +

    {trophy.name}

    + {trophy.organization ? ( +

    + + Organization: + + {trophy.organization.name} + + +

    + ) : null} + {trophy.creator ? ( +

    + + Created by + + {trophy.creator.username} + + +

    + ) : null} + {trophy.manager ? ( +

    + + Managed by + + {trophy.manager.username} + + +

    + ) : null} + {special ? ( +

    + {special.type === "supporter" + ? t("trophies:special.supporter.description") + : t("trophies:special.xp.description", { + value: special.value, + })} +

    + ) : null} +
    + {special ? null : ( +
    + + {t("trophies:details.tournamentHistory")} + + {tournaments.length > 0 ? ( + + ) : ( +

    + {t("trophies:details.noTournamentHistory")} +

    + )} +
    + )} +
    + + {t("trophies:details.owners")} + + {trophy.owners.length > 0 ? ( +
      + {trophy.owners.map((owner) => ( +
    • + {owner.username} + + ×{owner.count} + +
    • + ))} +
    + ) : ( +

    + {t("trophies:details.noOwners")} +

    + )} +
    +
    + ); +} diff --git a/app/features/trophies/routes/trophies.$id.wins.$userId.ts b/app/features/trophies/routes/trophies.$id.wins.$userId.ts new file mode 100644 index 000000000..c7e7ed498 --- /dev/null +++ b/app/features/trophies/routes/trophies.$id.wins.$userId.ts @@ -0,0 +1,33 @@ +import type { LoaderFunctionArgs } from "react-router"; +import * as R from "remeda"; +import { z } from "zod"; +import { getUser } from "~/features/auth/core/user.server"; +import * as UserCardRepository from "~/features/user-card/UserCardRepository.server"; +import type { SerializeFrom } from "~/utils/remix"; +import { parseParams } from "~/utils/remix.server"; +import { id } from "~/utils/zod"; +import * as TrophyRepository from "../TrophyRepository.server"; +import { canAccessTrophies } from "../trophies-utils"; + +export type TrophyWinsLoaderData = SerializeFrom; + +const paramsSchema = z.object({ id, userId: id }); + +export const loader = async ({ params }: LoaderFunctionArgs) => { + if (!canAccessTrophies(getUser())) { + throw new Response(null, { status: 404 }); + } + + const { id: trophyId, userId } = parseParams({ + params, + schema: paramsSchema, + }); + + const wins = await TrophyRepository.findWinsByOwner({ trophyId, userId }); + + const { userCards } = await UserCardRepository.findAllByUserIds({ + userIds: R.unique(wins.flatMap((win) => win.members.map((m) => m.id))), + }); + + return { wins, userCards }; +}; diff --git a/app/features/trophies/routes/trophies.module.css b/app/features/trophies/routes/trophies.module.css new file mode 100644 index 000000000..4bcdb1417 --- /dev/null +++ b/app/features/trophies/routes/trophies.module.css @@ -0,0 +1,64 @@ +.mainContent { + display: flex; + flex-direction: column; + gap: var(--s-6); +} + +.badgesLink { + color: var(--color-text-high); + font-size: var(--font-xs); +} + +.trophiesListContainer { + display: flex; + flex-direction: column; + gap: var(--s-4); +} + +.trophyDetailsContainer { + border: var(--border-style); + border-radius: var(--radius-box); + overflow: clip; +} + +@container (width < 560px) { + .trophyDetails { + height: 512px; + } +} + +.divider { + margin-top: var(--s-1); +} + +.trophyName { + font-weight: var(--weight-bold); + font-size: var(--font-lg); +} + +.trophyMeta { + color: var(--color-text-high); + font-size: var(--font-2xs); +} + +.owners { + display: flex; + flex-wrap: wrap; + padding: 0; + font-size: var(--font-xs); + gap: var(--s-2); + + & > li { + display: flex; + align-items: center; + gap: var(--s-1); + list-style: none; + border-right: var(--border-style); + padding-right: var(--s-2); + } +} + +.ownerCount { + color: var(--color-text-high); + font-size: var(--font-2xs); +} diff --git a/app/features/trophies/routes/trophies.new.module.css b/app/features/trophies/routes/trophies.new.module.css new file mode 100644 index 000000000..749e05dfe --- /dev/null +++ b/app/features/trophies/routes/trophies.new.module.css @@ -0,0 +1,323 @@ +.terms { + display: flex; + flex-direction: column; + gap: var(--s-4); + padding: var(--s-4); + border: var(--border-style); + border-radius: var(--radius-box); + background-color: var(--color-bg-high); + font-size: var(--font-sm); +} + +.termsTitle { + font-size: var(--font-md); + color: var(--color-error-high); + background-color: var(--color-error-low); + padding: var(--s-2); + border-radius: var(--radius-field); + border: 1px solid var(--color-error); +} + +.termsGroupTitle { + font-weight: var(--weight-semi); + margin-bottom: var(--s-1); +} + +.termsList { + list-style: disc; + padding-inline-start: var(--s-6); + display: flex; + flex-direction: column; + gap: var(--s-1); +} + +.termsDisclaimer { + font-weight: var(--weight-bold); + margin-bottom: var(--s-1); +} + +.termsAgreeButton { + align-self: center; +} + +.modelTextarea { + min-height: 160px; + font-family: var(--font-mono); + font-size: var(--font-xs); +} + +.trophyPreview { + border-radius: var(--radius-field); + background-color: var(--color-bg-high); + margin-top: var(--s-4); + + &:has(div) { + width: 100px; + height: auto; + aspect-ratio: 1 / 1; + border: var(--border-width) solid var(--tier-bg); + } +} + +.previewThemes { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--s-4); + margin-top: var(--s-4); +} + +.previewTheme { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--s-3); + padding: var(--s-3); + border: var(--border-style); + border-radius: var(--radius-field); + background-color: var(--color-bg); + + & .trophyPreview { + margin-top: 0; + width: 100%; + } +} + +.previewThemeLabel { + font-size: var(--font-xs); + font-weight: var(--weight-semi); + color: var(--color-text-high); +} + +.modelSpecs { + display: flex; + flex-direction: column; + gap: var(--s-3); + margin-top: var(--s-4); + padding: var(--s-3); + border: var(--border-style); + border-radius: var(--radius-box); + background-color: var(--color-bg-high); + font-size: var(--font-sm); +} + +.specList { + display: flex; + flex-direction: column; + gap: var(--s-1); + padding: 0; + margin-top: var(--s-1); + + & > li { + list-style: none; + } +} + +.specItem { + display: flex; + align-items: flex-start; + gap: var(--s-1-5); +} + +.specIcon { + width: 16px; + height: 16px; + flex-shrink: 0; + margin-top: 2px; + color: var(--color-text-high); +} + +.specIconPass { + color: var(--color-success); +} + +.specIconFail { + color: var(--color-error); +} + +.specIconWarn { + color: var(--color-warning); +} + +.specDetail { + color: var(--color-text-high); +} + +.pendingSpecs { + display: flex; + flex-wrap: wrap; + gap: var(--s-1) var(--s-2); + font-size: var(--font-2xs); + color: var(--color-text-high); +} + +.pendingSpecsWarn { + color: var(--color-warning); + font-weight: var(--weight-semi); +} + +.pendingSpecsError { + color: var(--color-error); + font-weight: var(--weight-semi); +} + +.descriptionTextarea { + min-height: 120px; +} + +.updateContainer { + display: flex; + flex-direction: column; + gap: var(--s-4); +} + +.diffHeader { + font-size: var(--font-2xs); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.diffGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--s-4) var(--s-2); + padding: var(--s-2); + border: var(--border-style); + border-radius: var(--radius-field); + background-color: var(--color-bg); + font-size: var(--font-xs); +} + +.diffField { + display: flex; + flex-direction: column; + min-width: 0; +} + +.diffLabel { + color: var(--color-text-high); + font-size: var(--font-2xs); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.diffValue { + overflow-wrap: anywhere; +} + +.diffOld { + color: var(--color-text-high); + text-decoration: line-through; +} + +.editingBadge { + padding: 0 var(--s-1-5); + border-radius: var(--radius-selector); + background-color: var(--color-text-accent); + color: var(--color-text-inverse); + font-size: var(--font-2xs); + font-weight: var(--weight-bold); +} + +.notManagerBadge { + padding: 0 var(--s-1-5); + border-radius: var(--radius-selector); + background-color: var(--color-warning-low); + color: var(--color-warning-high); + font-size: var(--font-2xs); + font-weight: var(--weight-bold); +} + +.pendingList { + display: flex; + flex-direction: column; + gap: var(--s-4); +} + +.pendingItem { + display: grid; + grid-template-columns: 100px 1fr; + gap: var(--s-4); + padding: var(--s-2); + border: var(--border-style); + border-radius: var(--radius-box); + background-color: var(--color-bg-high); + opacity: 1; + transition: opacity 0.2s ease; + + @starting-style { + opacity: 0; + } +} + +.pendingMain { + display: flex; + flex-direction: column; + gap: var(--s-2); + min-width: 0; +} + +.pendingHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s-2); + flex-wrap: wrap; +} + +.pendingName { + font-weight: var(--weight-semi); +} + +.pendingMeta { + font-size: var(--font-xs); + color: var(--color-text-high); +} + +.pendingDescription { + white-space: pre-wrap; + font-size: var(--font-sm); +} + +.pendingActions { + display: flex; + gap: var(--s-2); + flex-wrap: wrap; +} + +.declined { + background-color: var(--color-error-low); + border: 1px solid var(--color-error); + border-radius: var(--radius-field); + padding: var(--s-2); + font-size: var(--font-sm); + + > p { + color: var(--color-error-high); + font-weight: var(--weight-bold); + } +} + +.accepted { + background-color: var(--color-success-low); + border: 1px solid var(--color-success); + border-radius: var(--radius-field); + padding: var(--s-2); + font-size: var(--font-sm); + + > p { + color: var(--color-success-high); + font-weight: var(--weight-bold); + } +} + +.trophyPreviewButton { + all: unset; + cursor: pointer; +} + +.dialogForm { + display: flex; + flex-direction: column; + gap: var(--s-3); + min-width: 280px; +} diff --git a/app/features/trophies/routes/trophies.new.tsx b/app/features/trophies/routes/trophies.new.tsx new file mode 100644 index 000000000..89ad26f39 --- /dev/null +++ b/app/features/trophies/routes/trophies.new.tsx @@ -0,0 +1,966 @@ +import clsx from "clsx"; +import { Check, Clipboard, Dot, Trash2, TriangleAlert, X } from "lucide-react"; +import * as React from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { + Form, + Link, + type MetaFunction, + useFetcher, + useLoaderData, +} from "react-router"; +import { Alert } from "~/components/Alert"; +import { SendouButton } from "~/components/elements/Button"; +import { SendouDialog } from "~/components/elements/Dialog"; +import { OrganizationSearch } from "~/components/elements/OrganizationSearch"; +import { SendouSelect, SendouSelectItem } from "~/components/elements/Select"; +import { + SendouTab, + SendouTabList, + SendouTabPanel, + SendouTabs, +} from "~/components/elements/Tabs"; +import { UserSearch } from "~/components/elements/UserSearch"; +import { FormMessage } from "~/components/FormMessage"; +import { Label } from "~/components/Label"; +import { Main } from "~/components/Main"; +import type { CustomFieldRenderProps } from "~/form/FormField"; +import { SendouForm } from "~/form/SendouForm"; +import { useDebounce } from "~/hooks/useDebounce"; +import { metaTags } from "~/utils/remix"; +import type { SendouRouteHandle } from "~/utils/remix.server"; +import { + navIconUrl, + PICOCAD2_WEB_VIEWER_URL, + SENDOU_INK_DISCORD_URL, + TROPHIES_PAGE, + tournamentOrganizationPage, + userPage, +} from "~/utils/urls"; +import { action } from "../actions/trophies.new.server"; +import { Trophy, TrophyContextProvider } from "../components/Trophy"; +import { + analyzeTrophyModel, + type TrophyModelAnalysis, +} from "../core/model-analysis"; +import { + loader, + type NewTrophyLoaderData, +} from "../loaders/trophies.new.server"; +import { + TROPHY_APPROVALS_REQUIRED, + TROPHY_DECLINE_REASON_MAX_LENGTH, + TROPHY_MODEL_RECOMMENDED_MAX_DRAW_CALLS, + TROPHY_MODEL_RECOMMENDED_MAX_EFFECTS, + TROPHY_MODEL_RECOMMENDED_MAX_POLYS, + TROPHY_PENDING_PER_USER_LIMIT, +} from "../trophies-constants"; +import { + createTrophyFormSchema, + updateTrophyFormSchema, +} from "../trophies-schemas"; +import { + compressTrophyModel, + decompressTrophyModel, + useProgressiveRender, + useTrophyTermsAgreement, +} from "../trophies-utils"; +import styles from "./trophies.new.module.css"; + +export { action, loader }; + +export const handle: SendouRouteHandle = { + i18n: "trophies", + breadcrumb: () => ({ + imgPath: navIconUrl("trophies"), + href: TROPHIES_PAGE, + type: "IMAGE", + }), +}; + +export const meta: MetaFunction = (args) => { + return metaTags({ + title: "New trophy", + ogTitle: "Submit a new trophy", + location: args.location, + description: "Submit a new trophy for review.", + }); +}; + +export default function NewTrophyPage() { + const { t } = useTranslation(["trophies"]); + const data = useLoaderData(); + + return ( +
    + + + {t("trophies:new.tabs.upload")} + + {t("trophies:new.tabs.update")} + + + {t("trophies:new.tabs.pending")} + + {t("trophies:new.tabs.reviewed")} + + + {data.ownUnreviewedCount >= TROPHY_PENDING_PER_USER_LIMIT ? ( + + {t("trophies:new.form.limitReached", { + limit: TROPHY_PENDING_PER_USER_LIMIT, + })} + + ) : ( + + + + )} + + + {data.ownUnreviewedCount >= TROPHY_PENDING_PER_USER_LIMIT ? ( + + {t("trophies:new.form.limitReached", { + limit: TROPHY_PENDING_PER_USER_LIMIT, + })} + + ) : ( + + )} + + + + + + + + +
    + ); +} + +function TrophyTermsGate({ children }: { children: React.ReactNode }) { + const { t } = useTranslation(["trophies"]); + const { hasAgreedToTerms, agreeToTerms } = useTrophyTermsAgreement(); + + if (hasAgreedToTerms) return children; + + return ( +
    +

    {t("trophies:new.terms.title")}

    +

    {t("trophies:new.terms.intro")}

    +
    +

    + {t("trophies:new.terms.oneOff.title")} +

    +
      +
    • {t("trophies:new.terms.trustedOrg")}
    • +
    • {t("trophies:new.terms.oneOff.pastTournaments")}
    • +
    • {t("trophies:new.terms.oneOff.projectedTeams")}
    • +
    • {t("trophies:new.terms.oneOff.signedUpTeams")}
    • +
    +
    +
    +

    + {t("trophies:new.terms.series.title")} +

    +
      +
    • {t("trophies:new.terms.trustedOrg")}
    • +
    • {t("trophies:new.terms.series.consistentTeams")}
    • +
    +
    +
    +

    + {t("trophies:new.terms.disclaimer")} +

    +

    + + If you are unsure if your tournament is eligible, feel free to + acquire a pre-approval by creating a pre-approval request in the + + Discord server + + . + +

    +
    + + {t("trophies:new.terms.agree")} + +
    + ); +} + +function NewTrophyForm() { + return ( + + {({ FormField }) => ( + <> + + + {({ error, value, onChange }: CustomFieldRenderProps) => ( + + )} + + + {({ name, error, value, onChange }: CustomFieldRenderProps) => ( + + )} + + + + )} + + ); +} + +function UpdateTrophyTab() { + const { t } = useTranslation(["trophies"]); + const data = useLoaderData(); + const [selectedId, setSelectedId] = React.useState(null); + + const selectedTrophy = data.editableTrophies.find((t) => t.id === selectedId); + + return ( +
    + setSelectedId(key as number)} + > + {(trophy) => ( + + {trophy.name} + + )} + + {selectedTrophy ? ( + + ) : null} +
    + ); +} + +function UpdateTrophyForm({ + trophy, +}: { + trophy: NewTrophyLoaderData["editableTrophies"][number]; +}) { + const decompressedModel = decompressTrophyModel(trophy.model) ?? ""; + + return ( + + {({ FormField }) => ( + <> + + + {({ error, value, onChange }: CustomFieldRenderProps) => ( + + )} + + + {({ error, value, onChange }: CustomFieldRenderProps) => ( + + )} + + + {({ name, error, value, onChange }: CustomFieldRenderProps) => ( + + )} + + + + )} + + ); +} + +function ManagerField({ + error, + value, + onChange, +}: { + error?: string; + value: number | null; + onChange: (value: number | null) => void; +}) { + const { t } = useTranslation(["forms"]); + + return ( +
    + + onChange(user?.id ?? null)} + /> + {error ? {error} : null} +
    + ); +} + +function ModelField({ + name, + error, + value, + onChange, +}: { + name: string; + error?: string; + value: string; + onChange: (value: string) => void; +}) { + const { t } = useTranslation(["forms", "trophies"]); + const [preview, setPreview] = React.useState(() => buildModelPreview(value)); + + useDebounce(() => setPreview(buildModelPreview(value)), 500, [value]); + + return ( +
    + +