mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-04-26 09:20:24 -05:00
* Got something going * Style overwrites * width != height * More playing with lines * Migrations * Start bracket initial * Unhardcode stage generation params * Link to match page * Matches page initial * Support directly adding seed to map list generator * Add docs * Maps in matches page * Add invariant about tie breaker map pool * Fix PICNIC lacking tie breaker maps * Only link in bracket when tournament has started * Styled tournament roster inputs * Prefer IGN in tournament match page * ModeProgressIndicator * Some conditional rendering * Match action initial + better error display * Persist bestOf in DB * Resolve best of ahead of time * Move brackets-manager to core * Score reporting works * Clear winner on score report * ModeProgressIndicator: highlight winners * Fix inconsistent input * Better text when submitting match * mapCountPlayedInSetWithCertainty that works * UNDO_REPORT_SCORE implemented * Permission check when starting tournament * Remove IGN from upsert * View match results page * Source in DB * Match page waiting for teams * Move tournament bracket to feature folder * REOPEN_MATCH initial * Handle proper resetting of match * Inline bracket-manager * Syncify * Transactions * Handle match is locked gracefully * Match page auto refresh * Fix match refresh called "globally" * Bracket autoupdate * Move fillWithNullTillPowerOfTwo to utils with testing * Fix map lists not visible after tournament started * Optimize match events * Show UI while in progress to members * Fix start tournament alert not being responsive * Teams can check in * Fix map list 400 * xxx -> TODO * Seeds page * Remove map icons for team page * Don't display link to seeds after tournament has started * Admin actions initial * Change captain admin action * Make all hooks ts * Admin actions functioning * Fix validate error not displaying in CatchBoundary * Adjust validate args order * Remove admin loader * Make delete team button menancing * Only include checked in teams to bracket * Optimize to.id route loads * Working show map list generator toggle * Update full tournaments flow * Make full tournaments work with many start times * Handle undefined in crud * Dynamic stage banner * Handle default strat if map list generation fails * Fix crash on brackets if less than 2 teams * Add commented out test for reference * Add TODO * Add players from team during register * TrustRelationship * Prefers not to host feature * Last before merge * Rename some vars * More renames
275 lines
7.4 KiB
TypeScript
275 lines
7.4 KiB
TypeScript
import type {
|
|
ActionFunction,
|
|
LinksFunction,
|
|
LoaderArgs,
|
|
V2_MetaFunction,
|
|
SerializeFrom,
|
|
} from "@remix-run/node";
|
|
import { redirect } from "@remix-run/node";
|
|
import {
|
|
Form,
|
|
Link,
|
|
useLoaderData,
|
|
useNavigate,
|
|
useSearchParams,
|
|
} from "@remix-run/react";
|
|
import * as React from "react";
|
|
import { Button, LinkButton } from "~/components/Button";
|
|
import { Dialog } from "~/components/Dialog";
|
|
import { FormErrors } from "~/components/FormErrors";
|
|
import { SearchIcon } from "~/components/icons/Search";
|
|
import { Input } from "~/components/Input";
|
|
import { Main } from "~/components/Main";
|
|
import { SubmitButton } from "~/components/SubmitButton";
|
|
import { useTranslation } from "~/hooks/useTranslation";
|
|
import { useUser } from "~/modules/auth";
|
|
import { getUserId, requireUserId } from "~/modules/auth/user.server";
|
|
import { i18next } from "~/modules/i18n";
|
|
import { joinListToNaturalString } from "~/utils/arrays";
|
|
import type { SendouRouteHandle } from "~/utils/remix";
|
|
import { parseRequestFormData, validate } from "~/utils/remix";
|
|
import { makeTitle } from "~/utils/strings";
|
|
import {
|
|
mySlugify,
|
|
navIconUrl,
|
|
teamPage,
|
|
TEAM_SEARCH_PAGE,
|
|
userSubmittedImage,
|
|
} from "~/utils/urls";
|
|
import { allTeams } from "../queries/allTeams.server";
|
|
import { createNewTeam } from "../queries/createNewTeam.server";
|
|
import { TEAM } from "../team-constants";
|
|
import { createTeamSchema } from "../team-schemas.server";
|
|
import styles from "../team.css";
|
|
|
|
export const meta: V2_MetaFunction = ({
|
|
data,
|
|
}: {
|
|
data: SerializeFrom<typeof loader>;
|
|
}) => {
|
|
if (!data) return [];
|
|
|
|
return [{ title: data.title }];
|
|
};
|
|
|
|
export const links: LinksFunction = () => {
|
|
return [{ rel: "stylesheet", href: styles }];
|
|
};
|
|
|
|
export const action: ActionFunction = async ({ request }) => {
|
|
const user = await requireUserId(request);
|
|
const data = await parseRequestFormData({
|
|
request,
|
|
schema: createTeamSchema,
|
|
});
|
|
|
|
const teams = allTeams();
|
|
|
|
validate(
|
|
teams.every((team) =>
|
|
team.members.every((member) => member.id !== user.id)
|
|
),
|
|
"Already in a team"
|
|
);
|
|
|
|
// two teams can't have same customUrl
|
|
const customUrl = mySlugify(data.name);
|
|
if (teams.some((team) => team.customUrl === customUrl)) {
|
|
return {
|
|
errors: ["forms.errors.duplicateName"],
|
|
};
|
|
}
|
|
|
|
createNewTeam({
|
|
captainId: user.id,
|
|
name: data.name,
|
|
customUrl,
|
|
});
|
|
|
|
return redirect(teamPage(customUrl));
|
|
};
|
|
|
|
export const handle: SendouRouteHandle = {
|
|
i18n: ["team"],
|
|
breadcrumb: () => ({
|
|
imgPath: navIconUrl("t"),
|
|
href: TEAM_SEARCH_PAGE,
|
|
type: "IMAGE",
|
|
}),
|
|
};
|
|
|
|
export const loader = async ({ request }: LoaderArgs) => {
|
|
const user = await getUserId(request);
|
|
const t = await i18next.getFixedT(request);
|
|
|
|
const teams = allTeams().sort((teamA, teamB) => {
|
|
// show own team first always
|
|
if (user && teamA.members.some((m) => m.id === user.id)) {
|
|
return -1;
|
|
}
|
|
|
|
if (user && teamB.members.some((m) => m.id === user.id)) {
|
|
return 1;
|
|
}
|
|
|
|
// then full teams
|
|
if (teamA.members.length >= 4 && teamB.members.length < 4) {
|
|
return -1;
|
|
}
|
|
|
|
if (teamA.members.length < 4 && teamB.members.length >= 4) {
|
|
return 1;
|
|
}
|
|
|
|
// and as tiebreaker teams with a higher plus server tier member first
|
|
const lowestATeamPlusTier = Math.min(
|
|
...teamA.members.map((m) => m.plusTier ?? Infinity)
|
|
);
|
|
const lowestBTeamPlusTier = Math.min(
|
|
...teamB.members.map((m) => m.plusTier ?? Infinity)
|
|
);
|
|
|
|
if (lowestATeamPlusTier > lowestBTeamPlusTier) {
|
|
return 1;
|
|
}
|
|
|
|
if (lowestATeamPlusTier < lowestBTeamPlusTier) {
|
|
return -1;
|
|
}
|
|
|
|
return 0;
|
|
});
|
|
|
|
return {
|
|
title: makeTitle(t("pages.t")),
|
|
teams,
|
|
isMemberOfTeam: !user
|
|
? false
|
|
: teams.some((t) => t.members.some((m) => m.id === user.id)),
|
|
};
|
|
};
|
|
|
|
export default function TeamSearchPage() {
|
|
const { t } = useTranslation(["team"]);
|
|
const user = useUser();
|
|
const [inputValue, setInputValue] = React.useState("");
|
|
const data = useLoaderData<typeof loader>();
|
|
|
|
const filteredTeams = data.teams.filter((team) => {
|
|
if (!inputValue) return true;
|
|
|
|
const lowerCaseInput = inputValue.toLowerCase();
|
|
|
|
if (team.name.toLowerCase().includes(lowerCaseInput)) return true;
|
|
if (
|
|
team.members.some((m) =>
|
|
m.discordName.toLowerCase().includes(lowerCaseInput)
|
|
)
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
});
|
|
|
|
return (
|
|
<Main className="stack lg">
|
|
<NewTeamDialog />
|
|
{user && !data.isMemberOfTeam ? (
|
|
<LinkButton
|
|
size="tiny"
|
|
to="?new=true"
|
|
className="ml-auto"
|
|
testId="new-team-button"
|
|
>
|
|
{t("team:newTeam.button")}
|
|
</LinkButton>
|
|
) : null}
|
|
<Input
|
|
className="team-search__input"
|
|
icon={<SearchIcon className="team-search__icon" />}
|
|
value={inputValue}
|
|
onChange={(e) => setInputValue(e.target.value)}
|
|
placeholder={t("team:teamSearch.placeholder")}
|
|
testId="team-search-input"
|
|
/>
|
|
<div className="mt-6 stack lg">
|
|
{filteredTeams.map((team, i) => (
|
|
<Link
|
|
key={team.customUrl}
|
|
to={teamPage(team.customUrl)}
|
|
className="team-search__team"
|
|
>
|
|
{team.avatarSrc ? (
|
|
<img
|
|
src={userSubmittedImage(team.avatarSrc)}
|
|
alt=""
|
|
width={64}
|
|
height={64}
|
|
className="rounded-full"
|
|
loading="lazy"
|
|
/>
|
|
) : (
|
|
<div className="team-search__team__avatar-placeholder">
|
|
{team.name[0]}
|
|
</div>
|
|
)}
|
|
<div>
|
|
<div
|
|
className="team-search__team__name"
|
|
data-testid={`team-${i}`}
|
|
>
|
|
{team.name}
|
|
</div>
|
|
<div className="team-search__team__members">
|
|
{team.members.length === 1
|
|
? team.members[0]!.discordName
|
|
: joinListToNaturalString(
|
|
team.members.map((member) => member.discordName),
|
|
"&"
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</Main>
|
|
);
|
|
}
|
|
|
|
function NewTeamDialog() {
|
|
const { t } = useTranslation(["common", "team"]);
|
|
const navigate = useNavigate();
|
|
const [searchParams] = useSearchParams();
|
|
|
|
const isOpen = searchParams.get("new") === "true";
|
|
|
|
const close = () => navigate(TEAM_SEARCH_PAGE);
|
|
|
|
return (
|
|
<Dialog isOpen={isOpen} close={close} className="text-center">
|
|
<Form method="post" className="stack md">
|
|
<h2 className="text-sm">{t("team:newTeam.header")}</h2>
|
|
<div className="team-search__form-input-container">
|
|
<label htmlFor="name">{t("common:forms.name")}</label>
|
|
<input
|
|
id="name"
|
|
name="name"
|
|
minLength={TEAM.NAME_MIN_LENGTH}
|
|
maxLength={TEAM.NAME_MAX_LENGTH}
|
|
required
|
|
data-testid={isOpen ? "new-team-name-input" : undefined}
|
|
/>
|
|
</div>
|
|
<FormErrors namespace="team" />
|
|
<div className="stack horizontal md justify-center mt-4">
|
|
<SubmitButton>{t("common:actions.create")}</SubmitButton>
|
|
<Button variant="destructive" onClick={close}>
|
|
{t("common:actions.cancel")}
|
|
</Button>
|
|
</div>
|
|
</Form>
|
|
</Dialog>
|
|
);
|
|
}
|