mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-04-26 09:20:24 -05:00
* Tables * Clocks * Maplist preference selector * Fix SSR * Nav icon * RankedOrScrim * Map pool * Create group * Redirect logic * Persist map pool * Advance from preparing page * Rename query * Fix merge * Fix migration order * Seed groups * Find looking groups SQL * Renders something * More UI work * Back to 30min * Likes/dislikes * Always return own group * Fix like order * 3 tc/rm/cb -> 2 * Show only 3 weapons * Pass group size * Handle both liked and liked by same group * Fix SQL * Group preference frontend work * Morphing * Styling * Don't show group controls if not manager * Give/remove manager * Leave group * Leave with confirm * Delete likes when morphing groups * Clocks consistency * Remove bad invariant * Persist settings to local storage * Fix initial value flashing * Fix never resolving loading indicator * REFRESH_GROUP * Flip animations * Tweaks * Auto refresh logic * Groups of 4 seed * Reduce throwing * Load full groups initial * Create match * Match UI initial * Score reporter initial * Push footer down on match page * Score reporter knows when set ended * Score reporting untested * Show score after report * Align better * Look again with same group functionality * More migrations * Team on match page * Show confirmer before reporting score * Report weapons * Report weapos again by admin + skill changing * Handle no tiebreaker given to MapPool * Remove unranked * Remove support for "team id skill" * no-wrap -> nowrap * Preparing page work * Use common GroupCard component * Add some metas * MemberAdder in looking page * Fix GroupCard actions * Fix SZ only map list including other modes * Add season info * Prompt login * Joining team * Manage group on preparing page * Manage group on preparing page * Seed past matches * Add to seed * No map list preference when full group + fix expiry * Fix skill matchesCount calculation * Tiers initial work * Some progress on tiers * Tiering logic * MMR in group cards * Name to challenge * Team MMR * Big team rank icons * Adjust todos * Match score report with confirm * Allow regular members to report score * Handle reporting weapons edge cases * Add tier images * Improve GroupCard spacing * Refactor looking page * Looking mobile UI * Calculate skill only for current season * Divide groups visually when reporting weapons * Fix match page weapons sorting * Add cache to user skills+tier calculation * Admin report match score * Initial leaderboard * Cached leaderboard * Weapon category lb's * Populate SkillTeamUser in SendouQ * Team leaderboard filtered down * Add TODOs * Seasons initlal * Season weapons initial * Weapons stylized * Show rest weapons as + * Hide peak if same as current * Load matches SQL initial * Season matches UI initial * Take user id in account * Add weapons * Paginated matches * Fix pages count logic * Scroll top on data change * Day headers for matches * Link from user page to user seasons page * Summarize maps + ui initial * Map stats * Player info tabs * MMR chart * Chart adjustments * Handle basing team MMR on player MMR * Set initial MMR * Add info about discord to match page * Season support to tournaments * Get tournament skills as well for the graph * WIP * New team rating logic + misc other * tiered -> tiered.server * Update season starting time * TODOs * Add rules page * Hide elements correctly when off-season * Fix crash when only one player with skill * How-to video * Fix StartRank showing when not logged in * Make user leaderboard the default * Make Skill season non-nullable * Add suggested pass to match * Add rule * identifierToUserIds helper * Fix tiers not showing
598 lines
17 KiB
TypeScript
598 lines
17 KiB
TypeScript
import type {
|
|
ActionFunction,
|
|
LinksFunction,
|
|
LoaderArgs,
|
|
V2_MetaFunction,
|
|
} from "@remix-run/node";
|
|
import { redirect } from "@remix-run/node";
|
|
import { useFetcher, useLoaderData, useRevalidator } from "@remix-run/react";
|
|
import clsx from "clsx";
|
|
import * as React from "react";
|
|
import { Flipper } from "react-flip-toolkit";
|
|
import invariant from "tiny-invariant";
|
|
import { Main } from "~/components/Main";
|
|
import { SubmitButton } from "~/components/SubmitButton";
|
|
import { useIsMounted } from "~/hooks/useIsMounted";
|
|
import { useTranslation } from "~/hooks/useTranslation";
|
|
import { useVisibilityChange } from "~/hooks/useVisibilityChange";
|
|
import { getUser, requireUserId } from "~/modules/auth/user.server";
|
|
import { MapPool } from "~/modules/map-pool-serializer";
|
|
import {
|
|
parseRequestFormData,
|
|
validate,
|
|
type SendouRouteHandle,
|
|
} from "~/utils/remix";
|
|
import { assertUnreachable } from "~/utils/types";
|
|
import {
|
|
SENDOUQ_LOOKING_PAGE,
|
|
SENDOUQ_PAGE,
|
|
navIconUrl,
|
|
sendouQMatchPage,
|
|
} from "~/utils/urls";
|
|
import { GroupCard } from "../components/GroupCard";
|
|
import { groupAfterMorph, hasGroupManagerPerms } from "../core/groups";
|
|
import {
|
|
addSkillsToGroups,
|
|
censorGroups,
|
|
divideGroups,
|
|
filterOutGroupsWithIncompatibleMapListPreference,
|
|
groupExpiryStatus,
|
|
membersNeededForFull,
|
|
} from "../core/groups.server";
|
|
import { matchMapList } from "../core/match.server";
|
|
import { FULL_GROUP_SIZE } from "../q-constants";
|
|
import { lookingSchema } from "../q-schemas.server";
|
|
import { groupRedirectLocationByCurrentLocation } from "../q-utils";
|
|
import styles from "../q.css";
|
|
import { addLike } from "../queries/addLike.server";
|
|
import { addManagerRole } from "../queries/addManagerRole.server";
|
|
import { createMatch } from "../queries/createMatch.server";
|
|
import { deleteLike } from "../queries/deleteLike.server";
|
|
import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
|
|
import { findLikes } from "../queries/findLikes";
|
|
import { groupSize } from "../queries/groupSize.server";
|
|
import { groupSuccessorOwner } from "../queries/groupSuccessorOwner";
|
|
import { leaveGroup } from "../queries/leaveGroup.server";
|
|
import { likeExists } from "../queries/likeExists.server";
|
|
import { findLookingGroups } from "../queries/lookingGroups.server";
|
|
import { mapPoolByGroupId } from "../queries/mapPoolByGroupId.server";
|
|
import { morphGroups } from "../queries/morphGroups.server";
|
|
import { refreshGroup } from "../queries/refreshGroup.server";
|
|
import { removeManagerRole } from "../queries/removeManagerRole.server";
|
|
import { syncGroupTeamId } from "../queries/syncGroupTeamId.server";
|
|
import { makeTitle } from "~/utils/strings";
|
|
import { MemberAdder } from "../components/MemberAdder";
|
|
import type { LookingGroupWithInviteCode } from "../q-types";
|
|
import { trustedPlayersAvailableToPlay } from "../queries/usersInActiveGroup.server";
|
|
import { userSkills } from "~/features/mmr/tiered.server";
|
|
import { useWindowSize } from "~/hooks/useWindowSize";
|
|
import { Tab, Tabs } from "~/components/Tabs";
|
|
|
|
export const handle: SendouRouteHandle = {
|
|
i18n: ["q"],
|
|
breadcrumb: () => ({
|
|
imgPath: navIconUrl("sendouq"),
|
|
href: SENDOUQ_LOOKING_PAGE,
|
|
type: "IMAGE",
|
|
}),
|
|
};
|
|
|
|
export const links: LinksFunction = () => {
|
|
return [{ rel: "stylesheet", href: styles }];
|
|
};
|
|
|
|
export const meta: V2_MetaFunction = () => {
|
|
return [{ title: makeTitle("SendouQ") }];
|
|
};
|
|
|
|
// this function doesn't throw normally because we are assuming
|
|
// if there is a validation error the user saw stale data
|
|
// and when we return null we just force a refresh
|
|
export const action: ActionFunction = async ({ request }) => {
|
|
const user = await requireUserId(request);
|
|
const data = await parseRequestFormData({
|
|
request,
|
|
schema: lookingSchema,
|
|
});
|
|
const currentGroup = findCurrentGroupByUserId(user.id);
|
|
if (!currentGroup) return null;
|
|
|
|
// this throws because there should normally be no way user loses ownership by the action of some other user
|
|
const validateIsGroupOwner = () =>
|
|
validate(currentGroup.role === "OWNER", "Not owner");
|
|
const isGroupManager = () =>
|
|
currentGroup.role === "MANAGER" || currentGroup.role === "OWNER";
|
|
|
|
switch (data._action) {
|
|
case "LIKE": {
|
|
if (!isGroupManager()) return null;
|
|
|
|
addLike({
|
|
likerGroupId: currentGroup.id,
|
|
targetGroupId: data.targetGroupId,
|
|
});
|
|
refreshGroup(currentGroup.id);
|
|
|
|
break;
|
|
}
|
|
case "UNLIKE": {
|
|
if (!isGroupManager()) return null;
|
|
|
|
deleteLike({
|
|
likerGroupId: currentGroup.id,
|
|
targetGroupId: data.targetGroupId,
|
|
});
|
|
refreshGroup(currentGroup.id);
|
|
|
|
break;
|
|
}
|
|
case "GROUP_UP": {
|
|
if (!isGroupManager()) return null;
|
|
if (
|
|
!likeExists({
|
|
targetGroupId: currentGroup.id,
|
|
likerGroupId: data.targetGroupId,
|
|
})
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const lookingGroups = findLookingGroups({
|
|
maxGroupSize: membersNeededForFull(groupSize(currentGroup.id)),
|
|
ownGroupId: currentGroup.id,
|
|
});
|
|
|
|
const ourGroup = lookingGroups.find(
|
|
(group) => group.id === currentGroup.id
|
|
);
|
|
if (!ourGroup) return null;
|
|
const theirGroup = lookingGroups.find(
|
|
(group) => group.id === data.targetGroupId
|
|
);
|
|
if (!theirGroup) return null;
|
|
|
|
const { id: survivingGroupId } = groupAfterMorph({
|
|
liker: "THEM",
|
|
ourGroup,
|
|
theirGroup,
|
|
});
|
|
|
|
const otherGroup =
|
|
ourGroup.id === survivingGroupId ? theirGroup : ourGroup;
|
|
|
|
invariant(ourGroup.members, "our group has no members");
|
|
invariant(otherGroup.members, "other group has no members");
|
|
|
|
morphGroups({
|
|
survivingGroupId,
|
|
otherGroupId: otherGroup.id,
|
|
newMembers: otherGroup.members.map((m) => m.id),
|
|
});
|
|
refreshGroup(survivingGroupId);
|
|
|
|
if (
|
|
ourGroup.members.length + otherGroup.members.length ===
|
|
FULL_GROUP_SIZE
|
|
) {
|
|
syncGroupTeamId(survivingGroupId);
|
|
}
|
|
|
|
break;
|
|
}
|
|
case "MATCH_UP": {
|
|
if (!isGroupManager()) return null;
|
|
if (
|
|
!likeExists({
|
|
targetGroupId: currentGroup.id,
|
|
likerGroupId: data.targetGroupId,
|
|
})
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const lookingGroups = findLookingGroups({
|
|
minGroupSize: FULL_GROUP_SIZE,
|
|
ownGroupId: currentGroup.id,
|
|
});
|
|
|
|
const ourGroup = lookingGroups.find(
|
|
(group) => group.id === currentGroup.id
|
|
);
|
|
if (!ourGroup) return null;
|
|
const theirGroup = lookingGroups.find(
|
|
(group) => group.id === data.targetGroupId
|
|
);
|
|
if (!theirGroup) return null;
|
|
|
|
const createdMatch = createMatch({
|
|
alphaGroupId: ourGroup.id,
|
|
bravoGroupId: theirGroup.id,
|
|
mapList: matchMapList({
|
|
ourGroup,
|
|
theirGroup,
|
|
ourMapPool: new MapPool(mapPoolByGroupId(ourGroup.id)),
|
|
theirMapPool: new MapPool(mapPoolByGroupId(theirGroup.id)),
|
|
}),
|
|
});
|
|
|
|
throw redirect(sendouQMatchPage(createdMatch.id));
|
|
}
|
|
case "GIVE_MANAGER": {
|
|
validateIsGroupOwner();
|
|
|
|
addManagerRole({
|
|
groupId: currentGroup.id,
|
|
userId: data.userId,
|
|
});
|
|
refreshGroup(currentGroup.id);
|
|
|
|
break;
|
|
}
|
|
case "REMOVE_MANAGER": {
|
|
validateIsGroupOwner();
|
|
|
|
removeManagerRole({
|
|
groupId: currentGroup.id,
|
|
userId: data.userId,
|
|
});
|
|
refreshGroup(currentGroup.id);
|
|
|
|
break;
|
|
}
|
|
case "LEAVE_GROUP": {
|
|
let newOwnerId: number | null = null;
|
|
if (currentGroup.role === "OWNER") {
|
|
newOwnerId = groupSuccessorOwner(currentGroup.id);
|
|
}
|
|
|
|
leaveGroup({
|
|
groupId: currentGroup.id,
|
|
userId: user.id,
|
|
newOwnerId,
|
|
wasOwner: currentGroup.role === "OWNER",
|
|
});
|
|
|
|
throw redirect(SENDOUQ_PAGE);
|
|
}
|
|
case "REFRESH_GROUP": {
|
|
refreshGroup(currentGroup.id);
|
|
|
|
break;
|
|
}
|
|
default: {
|
|
assertUnreachable(data);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
export const loader = async ({ request }: LoaderArgs) => {
|
|
const user = await getUser(request);
|
|
|
|
const currentGroup = user ? findCurrentGroupByUserId(user.id) : undefined;
|
|
const redirectLocation = groupRedirectLocationByCurrentLocation({
|
|
group: currentGroup,
|
|
currentLocation: "looking",
|
|
});
|
|
|
|
if (redirectLocation) {
|
|
throw redirect(redirectLocation);
|
|
}
|
|
|
|
invariant(currentGroup, "currentGroup is undefined");
|
|
|
|
const currentGroupSize = groupSize(currentGroup.id);
|
|
const groupIsFull = currentGroupSize === FULL_GROUP_SIZE;
|
|
|
|
const dividedGroups = divideGroups({
|
|
groups: findLookingGroups({
|
|
maxGroupSize: groupIsFull
|
|
? undefined
|
|
: membersNeededForFull(currentGroupSize),
|
|
minGroupSize: groupIsFull ? FULL_GROUP_SIZE : undefined,
|
|
ownGroupId: currentGroup.id,
|
|
}),
|
|
ownGroupId: currentGroup.id,
|
|
likes: findLikes(currentGroup.id),
|
|
});
|
|
|
|
const groupsWithSkills = addSkillsToGroups({
|
|
groups: dividedGroups,
|
|
...(await userSkills()),
|
|
});
|
|
|
|
const compatibleGroups = groupIsFull
|
|
? filterOutGroupsWithIncompatibleMapListPreference(groupsWithSkills)
|
|
: groupsWithSkills;
|
|
|
|
const censoredGroups = censorGroups({
|
|
groups: compatibleGroups,
|
|
showMembers: !groupIsFull,
|
|
showInviteCode: hasGroupManagerPerms(currentGroup.role) && !groupIsFull,
|
|
});
|
|
|
|
return {
|
|
groups: censoredGroups,
|
|
role: currentGroup.role,
|
|
lastUpdated: new Date().getTime(),
|
|
expiryStatus: groupExpiryStatus(currentGroup),
|
|
trustedPlayers: hasGroupManagerPerms(currentGroup.role)
|
|
? trustedPlayersAvailableToPlay(user!)
|
|
: [],
|
|
};
|
|
};
|
|
|
|
export default function QLookingPage() {
|
|
const data = useLoaderData<typeof loader>();
|
|
useAutoRefresh();
|
|
|
|
const ownGroup = data.groups.own as LookingGroupWithInviteCode;
|
|
|
|
return (
|
|
<Main className="stack lg">
|
|
<div className="stack sm">
|
|
<InfoText />
|
|
<div className="q__own-group-container">
|
|
<GroupCard
|
|
group={data.groups.own}
|
|
mapListPreference={data.groups.own.mapListPreference}
|
|
ownRole={data.role}
|
|
ownGroup
|
|
/>
|
|
</div>
|
|
</div>
|
|
{ownGroup.inviteCode ? (
|
|
<MemberAdder
|
|
inviteCode={ownGroup.inviteCode}
|
|
trustedPlayers={data.trustedPlayers}
|
|
/>
|
|
) : null}
|
|
<Groups />
|
|
</Main>
|
|
);
|
|
}
|
|
|
|
// TODO: could be improved e.g. don't refresh when group has expired
|
|
// or we got new data in the last 20 seconds
|
|
function useAutoRefresh() {
|
|
const { revalidate } = useRevalidator();
|
|
const visibility = useVisibilityChange();
|
|
|
|
React.useEffect(() => {
|
|
// when user comes back to this tab
|
|
if (visibility === "visible") {
|
|
revalidate();
|
|
}
|
|
|
|
// ...as well as every 20 seconds
|
|
const interval = setInterval(() => {
|
|
if (visibility === "hidden") return;
|
|
revalidate();
|
|
}, 20 * 1000);
|
|
|
|
return () => {
|
|
clearInterval(interval);
|
|
};
|
|
}, [visibility, revalidate]);
|
|
}
|
|
|
|
function InfoText() {
|
|
const { i18n } = useTranslation();
|
|
const isMounted = useIsMounted();
|
|
const data = useLoaderData<typeof loader>();
|
|
const fetcher = useFetcher();
|
|
|
|
if (data.expiryStatus === "EXPIRED") {
|
|
return (
|
|
<fetcher.Form
|
|
method="post"
|
|
className="text-xs text-lighter ml-auto text-error stack horizontal sm"
|
|
>
|
|
Group hidden due to inactivity. Still looking?{" "}
|
|
<SubmitButton
|
|
size="tiny"
|
|
variant="minimal"
|
|
_action="REFRESH_GROUP"
|
|
state={fetcher.state}
|
|
>
|
|
Click here
|
|
</SubmitButton>
|
|
</fetcher.Form>
|
|
);
|
|
}
|
|
|
|
if (data.expiryStatus === "EXPIRING_SOON") {
|
|
return (
|
|
<fetcher.Form
|
|
method="post"
|
|
className="text-xs text-lighter ml-auto text-warning stack horizontal sm"
|
|
>
|
|
Group will be hidden soon due to inactivity. Still looking?{" "}
|
|
<SubmitButton
|
|
size="tiny"
|
|
variant="minimal"
|
|
_action="REFRESH_GROUP"
|
|
state={fetcher.state}
|
|
>
|
|
Click here
|
|
</SubmitButton>
|
|
</fetcher.Form>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={clsx("text-xs text-lighter ml-auto", {
|
|
invisible: !isMounted,
|
|
})}
|
|
>
|
|
{isMounted
|
|
? `Last updated at ${new Date(data.lastUpdated).toLocaleTimeString(
|
|
i18n.language
|
|
)}`
|
|
: "Placeholder"}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Groups() {
|
|
const data = useLoaderData<typeof loader>();
|
|
const isMounted = useIsMounted();
|
|
const { width } = useWindowSize();
|
|
|
|
if (data.expiryStatus === "EXPIRED" || !isMounted) return null;
|
|
|
|
if (width < 750) return <MobileGroupCards />;
|
|
return <GroupCardColumns />;
|
|
}
|
|
|
|
function MobileGroupCards() {
|
|
const data = useLoaderData<typeof loader>();
|
|
const [tab, setTab] = React.useState<"received" | "neutral" | "given">(
|
|
"neutral"
|
|
);
|
|
|
|
const isFullGroup = data.groups.own.members!.length === FULL_GROUP_SIZE;
|
|
|
|
const groups =
|
|
tab === "received"
|
|
? data.groups.likesReceived
|
|
: tab === "given"
|
|
? data.groups.likesGiven
|
|
: data.groups.neutral;
|
|
|
|
return (
|
|
<div className="mt-6">
|
|
<Tabs compact>
|
|
<Tab active={tab === "received"} onClick={() => setTab("received")}>
|
|
Received ({data.groups.likesReceived.length})
|
|
</Tab>
|
|
<Tab active={tab === "neutral"} onClick={() => setTab("neutral")}>
|
|
Neutral ({data.groups.neutral.length})
|
|
</Tab>
|
|
<Tab active={tab === "given"} onClick={() => setTab("given")}>
|
|
Given ({data.groups.likesGiven.length})
|
|
</Tab>
|
|
</Tabs>
|
|
<div className="stack sm q__mobile-groups-container">
|
|
{groups.map((group) => {
|
|
const { mapListPreference } = groupAfterMorph({
|
|
liker: tab === "received" ? "THEM" : "US",
|
|
ourGroup: data.groups.own,
|
|
theirGroup: group,
|
|
});
|
|
|
|
const action =
|
|
tab === "neutral"
|
|
? "LIKE"
|
|
: tab === "given"
|
|
? "UNLIKE"
|
|
: isFullGroup
|
|
? "MATCH_UP"
|
|
: "GROUP_UP";
|
|
|
|
return (
|
|
<GroupCard
|
|
key={group.id}
|
|
group={group}
|
|
action={action}
|
|
mapListPreference={mapListPreference}
|
|
ownRole={data.role}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function GroupCardColumns() {
|
|
const data = useLoaderData<typeof loader>();
|
|
|
|
const isFullGroup = data.groups.own.members!.length === FULL_GROUP_SIZE;
|
|
|
|
return (
|
|
<Flipper
|
|
flipKey={`${data.groups.likesReceived
|
|
.map((g) => g.id)
|
|
.join("")}-${data.groups.neutral
|
|
.map((g) => g.id)
|
|
.join("")}-${data.groups.likesGiven.map((g) => g.id).join("")}`}
|
|
>
|
|
<div className="q__groups-container">
|
|
<div>
|
|
<h2 className="text-sm text-center mb-2">
|
|
{isFullGroup ? "Challenges received" : "Groups that asked you"}
|
|
</h2>
|
|
<div className="stack sm">
|
|
{data.groups.likesReceived.map((group) => {
|
|
const { mapListPreference } = groupAfterMorph({
|
|
liker: "THEM",
|
|
ourGroup: data.groups.own,
|
|
theirGroup: group,
|
|
});
|
|
|
|
return (
|
|
<GroupCard
|
|
key={group.id}
|
|
group={group}
|
|
action={isFullGroup ? "MATCH_UP" : "GROUP_UP"}
|
|
mapListPreference={mapListPreference}
|
|
ownRole={data.role}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
<div className="w-full">
|
|
<h2 className="text-sm text-center mb-2 invisible">Neutral</h2>
|
|
<div className="stack sm">
|
|
{data.groups.neutral.map((group) => {
|
|
const { mapListPreference } = groupAfterMorph({
|
|
liker: "US",
|
|
ourGroup: data.groups.own,
|
|
theirGroup: group,
|
|
});
|
|
|
|
return (
|
|
<GroupCard
|
|
key={group.id}
|
|
group={group}
|
|
action="LIKE"
|
|
mapListPreference={mapListPreference}
|
|
ownRole={data.role}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<h2 className="text-sm text-center mb-2">
|
|
{isFullGroup ? "Challenges issued" : "Groups you asked"}
|
|
</h2>
|
|
<div className="stack sm">
|
|
{data.groups.likesGiven.map((group) => {
|
|
const { mapListPreference } = groupAfterMorph({
|
|
liker: "US",
|
|
ourGroup: data.groups.own,
|
|
theirGroup: group,
|
|
});
|
|
|
|
return (
|
|
<GroupCard
|
|
key={group.id}
|
|
group={group}
|
|
action="UNLIKE"
|
|
mapListPreference={mapListPreference}
|
|
ownRole={data.role}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Flipper>
|
|
);
|
|
}
|