sendou.ink/app/features/team/routes/t.$customUrl.edit.tsx
Kalle ef78d3a2c2
Tournament full (#1373)
* 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
2023-05-15 22:37:43 +03:00

264 lines
6.8 KiB
TypeScript

import type {
LinksFunction,
V2_MetaFunction,
SerializeFrom,
} from "@remix-run/node";
import {
redirect,
type ActionFunction,
type LoaderArgs,
} from "@remix-run/node";
import { Form, Link, useLoaderData } from "@remix-run/react";
import * as React from "react";
import { Button } from "~/components/Button";
import { CustomizedColorsInput } from "~/components/CustomizedColorsInput";
import { FormErrors } from "~/components/FormErrors";
import { FormMessage } from "~/components/FormMessage";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { useTranslation } from "~/hooks/useTranslation";
import { requireUserId } from "~/modules/auth/user.server";
import {
notFoundIfFalsy,
parseRequestFormData,
validate,
type SendouRouteHandle,
} from "~/utils/remix";
import { makeTitle } from "~/utils/strings";
import { assertUnreachable } from "~/utils/types";
import {
mySlugify,
navIconUrl,
teamPage,
TEAM_SEARCH_PAGE,
uploadImagePage,
} from "~/utils/urls";
import { deleteTeam } from "../queries/deleteTeam.server";
import { edit } from "../queries/edit.server";
import { findByIdentifier } from "../queries/findByIdentifier.server";
import { TEAM } from "../team-constants";
import { editTeamSchema, teamParamsSchema } from "../team-schemas.server";
import { canAddCustomizedColors, isTeamOwner } from "../team-utils";
import styles from "../team.css";
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
};
export const meta: V2_MetaFunction = ({
data,
}: {
data: SerializeFrom<typeof loader>;
}) => {
if (!data) return [];
return [{ title: makeTitle(data.team.name) }];
};
export const handle: SendouRouteHandle = {
i18n: ["team"],
breadcrumb: ({ match }) => {
const data = match.data as SerializeFrom<typeof loader> | undefined;
if (!data) return [];
return [
{
imgPath: navIconUrl("t"),
href: TEAM_SEARCH_PAGE,
type: "IMAGE",
},
{
text: data.team.name,
href: teamPage(data.team.customUrl),
type: "TEXT",
},
];
},
};
export const action: ActionFunction = async ({ request, params }) => {
const user = await requireUserId(request);
const { customUrl } = teamParamsSchema.parse(params);
const { team } = notFoundIfFalsy(findByIdentifier(customUrl));
validate(isTeamOwner({ team, user }), "You are not the team owner");
const data = await parseRequestFormData({
request,
schema: editTeamSchema,
});
switch (data._action) {
case "DELETE": {
deleteTeam(team.id);
return redirect(TEAM_SEARCH_PAGE);
}
case "EDIT": {
const newCustomUrl = mySlugify(data.name);
const existing = findByIdentifier(newCustomUrl);
// can't take someone else's custom url
if (existing && existing.team.id !== team.id) {
return {
errors: ["forms.errors.duplicateName"],
};
}
const editedTeam = edit({
id: team.id,
customUrl: newCustomUrl,
...data,
});
return redirect(teamPage(editedTeam.customUrl));
}
default: {
assertUnreachable(data);
}
}
};
export const loader = async ({ request, params }: LoaderArgs) => {
const user = await requireUserId(request);
const { customUrl } = teamParamsSchema.parse(params);
const { team, css } = notFoundIfFalsy(findByIdentifier(customUrl));
if (!isTeamOwner({ team, user })) {
throw redirect(teamPage(customUrl));
}
return { team, css };
};
export default function EditTeamPage() {
const { t } = useTranslation(["common", "team"]);
const { team, css } = useLoaderData<typeof loader>();
return (
<Main className="half-width">
<FormWithConfirm
dialogHeading={t("team:deleteTeam.header", { teamName: team.name })}
fields={[["_action", "DELETE"]]}
>
<Button
className="ml-auto"
variant="minimal-destructive"
data-testid="delete-team-button"
>
{t("team:actionButtons.deleteTeam")}
</Button>
</FormWithConfirm>
<Form method="post" className="stack md items-start">
<ImageUploadLinks />
{canAddCustomizedColors(team) ? (
<CustomizedColorsInput initialColors={css} />
) : null}
<NameInput />
<TwitterInput />
<BioTextarea />
<SubmitButton
className="mt-4"
_action="EDIT"
testId="edit-team-submit-button"
>
{t("common:actions.submit")}
</SubmitButton>
<FormErrors namespace="team" />
</Form>
</Main>
);
}
function ImageUploadLinks() {
const { t } = useTranslation(["team"]);
return (
<div>
<Label>{t("team:forms.fields.uploadImages")}</Label>
<ol className="team__image-links-list">
<li>
<Link to={uploadImagePage("team-pfp")}>
{t("team:forms.fields.uploadImages.pfp")}
</Link>
</li>
<li>
<Link to={uploadImagePage("team-banner")}>
{t("team:forms.fields.uploadImages.banner")}
</Link>
</li>
</ol>
</div>
);
}
function NameInput() {
const { t } = useTranslation(["common", "team"]);
const { team } = useLoaderData<typeof loader>();
return (
<div>
<Label htmlFor="title" required>
{t("common:forms.name")}
</Label>
<input
id="name"
name="name"
required
minLength={TEAM.NAME_MIN_LENGTH}
maxLength={TEAM.NAME_MAX_LENGTH}
defaultValue={team.name}
data-testid="name-input"
/>
<FormMessage type="info">{t("team:forms.info.name")}</FormMessage>
</div>
);
}
function TwitterInput() {
const { t } = useTranslation(["team"]);
const { team } = useLoaderData<typeof loader>();
return (
<div>
<Label htmlFor="twitter">{t("team:forms.fields.teamTwitter")}</Label>
<input
id="twitter"
name="twitter"
maxLength={TEAM.TWITTER_MAX_LENGTH}
defaultValue={team.twitter}
data-testid="twitter-input"
/>
</div>
);
}
function BioTextarea() {
const { t } = useTranslation(["team"]);
const { team } = useLoaderData<typeof loader>();
const [value, setValue] = React.useState(team.bio ?? "");
return (
<div className="u-edit__bio-container">
<Label
htmlFor="bio"
valueLimits={{ current: value.length, max: TEAM.BIO_MAX_LENGTH }}
>
{t("team:forms.fields.bio")}
</Label>
<textarea
id="bio"
name="bio"
value={value}
onChange={(e) => setValue(e.target.value)}
maxLength={TEAM.BIO_MAX_LENGTH}
data-testid="bio-textarea"
/>
</div>
);
}