mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-25 20:50:14 -05:00
Teams: Edit page
This commit is contained in:
@@ -1,7 +1,12 @@
|
||||
import { useActionData } from "@remix-run/react";
|
||||
import type { CustomTypeOptions } from "react-i18next";
|
||||
import { useTranslation } from "~/hooks/useTranslation";
|
||||
|
||||
export function FormErrors({ namespace }: { namespace: "user" | "calendar" }) {
|
||||
export function FormErrors({
|
||||
namespace,
|
||||
}: {
|
||||
namespace: keyof CustomTypeOptions["resources"];
|
||||
}) {
|
||||
const { t } = useTranslation(["common", namespace]);
|
||||
const actionData = useActionData<{ errors?: string[] }>();
|
||||
|
||||
|
||||
29
app/features/team/queries/edit.server.ts
Normal file
29
app/features/team/queries/edit.server.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { sql } from "~/db/sql";
|
||||
import type { Team } from "~/db/types";
|
||||
|
||||
const stm = sql.prepare(/*sql*/ `
|
||||
update "Team"
|
||||
set
|
||||
"name" = @name,
|
||||
"customUrl" = @customUrl,
|
||||
"bio" = @bio,
|
||||
"twitter" = @twitter
|
||||
where "id" = @id
|
||||
returning *
|
||||
`);
|
||||
|
||||
export function edit({
|
||||
id,
|
||||
name,
|
||||
customUrl,
|
||||
bio,
|
||||
twitter,
|
||||
}: Pick<Team, "id" | "name" | "customUrl" | "bio" | "twitter">) {
|
||||
return stm.get({
|
||||
id,
|
||||
name,
|
||||
customUrl,
|
||||
bio,
|
||||
twitter,
|
||||
}) as Team;
|
||||
}
|
||||
@@ -19,15 +19,18 @@ const teamStm = sql.prepare(/*sql*/ `
|
||||
left join "TeamMember" on "TeamMember"."teamId" = "t"."id"
|
||||
left join "User" on "User"."id" = "TeamMember"."userId"
|
||||
where "t"."customUrl" = @customUrl
|
||||
and "t"."deletedAt" is null
|
||||
group by "t"."id"
|
||||
`);
|
||||
|
||||
const membersStm = sql.prepare(/*sql*/ `
|
||||
select
|
||||
"User"."id",
|
||||
"User"."discordName",
|
||||
"User"."discordAvatar",
|
||||
"User"."discordId",
|
||||
"TeamMember"."role",
|
||||
"TeamMember"."isOwner",
|
||||
json_group_array("UserWeapon"."weaponSplId") as "weapons"
|
||||
from "TeamMember"
|
||||
join "User" on "User"."id" = "TeamMember"."userId"
|
||||
@@ -45,18 +48,19 @@ type TeamRow =
|
||||
| null;
|
||||
|
||||
type MemberRows = Array<
|
||||
Pick<User, "discordName" | "discordAvatar" | "discordId"> &
|
||||
Pick<TeamMember, "role"> & { weapons: string }
|
||||
Pick<User, "id" | "discordName" | "discordAvatar" | "discordId"> &
|
||||
Pick<TeamMember, "role" | "isOwner"> & { weapons: string }
|
||||
>;
|
||||
|
||||
export function findByIdentifier(customUrl: string): DetailedTeam | null {
|
||||
const team = teamStm.get({ customUrl }) as TeamRow;
|
||||
const team = teamStm.get({ customUrl: customUrl.toLowerCase() }) as TeamRow;
|
||||
|
||||
if (!team) return null;
|
||||
|
||||
const members = membersStm.all({ teamId: team.id }) as MemberRows;
|
||||
|
||||
return {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
twitter: team.twitter ?? undefined,
|
||||
bio: team.bio ?? undefined,
|
||||
@@ -66,10 +70,12 @@ export function findByIdentifier(customUrl: string): DetailedTeam | null {
|
||||
bannerSrc: team.bannerSrc,
|
||||
countries: removeDuplicates(JSON.parse(team.countries).filter(Boolean)),
|
||||
members: members.map((member) => ({
|
||||
id: member.id,
|
||||
discordAvatar: member.discordAvatar,
|
||||
discordId: member.discordId,
|
||||
discordName: member.discordName,
|
||||
role: member.role ?? undefined,
|
||||
isOwner: Boolean(member.isOwner),
|
||||
weapons: JSON.parse(member.weapons).filter(Boolean),
|
||||
})),
|
||||
results: undefined,
|
||||
|
||||
162
app/features/team/routes/t.$customUrl.edit.tsx
Normal file
162
app/features/team/routes/t.$customUrl.edit.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
type LoaderArgs,
|
||||
redirect,
|
||||
type ActionFunction,
|
||||
} from "@remix-run/node";
|
||||
import * as React from "react";
|
||||
import { Form, useLoaderData } from "@remix-run/react";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { Label } from "~/components/Label";
|
||||
import { Main } from "~/components/Main";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { useTranslation } from "~/hooks/useTranslation";
|
||||
import { requireUser } from "~/modules/auth";
|
||||
import {
|
||||
notFoundIfFalsy,
|
||||
parseRequestFormData,
|
||||
type SendouRouteHandle,
|
||||
validate,
|
||||
} from "~/utils/remix";
|
||||
import { mySlugify, teamPage } from "~/utils/urls";
|
||||
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 { isTeamOwner } from "../team-utils";
|
||||
import { FormErrors } from "~/components/FormErrors";
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["team"],
|
||||
// breadcrumb: () => ({
|
||||
// imgPath: navIconUrl("object-damage-calculator"),
|
||||
// href: OBJECT_DAMAGE_CALCULATOR_URL,
|
||||
// type: "IMAGE",
|
||||
// }),
|
||||
};
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const user = await requireUser(request);
|
||||
const { customUrl } = teamParamsSchema.parse(params);
|
||||
|
||||
const team = notFoundIfFalsy(findByIdentifier(customUrl));
|
||||
|
||||
validate(isTeamOwner({ team, user }));
|
||||
|
||||
const data = await parseRequestFormData({
|
||||
request,
|
||||
schema: editTeamSchema,
|
||||
});
|
||||
|
||||
const newCustomUrl = mySlugify(data.name);
|
||||
const existingTeam = findByIdentifier(newCustomUrl);
|
||||
|
||||
// can't take someone else's custom url
|
||||
if (existingTeam && existingTeam.id !== team.id) {
|
||||
return {
|
||||
errors: ["forms.errors.duplicateName"],
|
||||
};
|
||||
}
|
||||
|
||||
const editedTeam = edit({
|
||||
id: team.id,
|
||||
customUrl: newCustomUrl,
|
||||
...data,
|
||||
});
|
||||
|
||||
return redirect(teamPage(editedTeam.customUrl));
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const { customUrl } = teamParamsSchema.parse(params);
|
||||
|
||||
const team = notFoundIfFalsy(findByIdentifier(customUrl));
|
||||
|
||||
if (!isTeamOwner({ team, user })) {
|
||||
throw redirect(teamPage(customUrl));
|
||||
}
|
||||
|
||||
return { team };
|
||||
};
|
||||
|
||||
export default function EditTeamPage() {
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
return (
|
||||
<Main className="half-width">
|
||||
<Form method="post" className="stack md items-start">
|
||||
<NameInput />
|
||||
<TwitterInput />
|
||||
<BioTextarea />
|
||||
<SubmitButton className="mt-4">
|
||||
{t("common:actions.submit")}
|
||||
</SubmitButton>
|
||||
<FormErrors namespace="team" />
|
||||
</Form>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function NameInput() {
|
||||
const { t } = useTranslation(["common", "team"]);
|
||||
const { team } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor="title" required>
|
||||
{t("common:forms.name")}
|
||||
</Label>
|
||||
<input
|
||||
name="name"
|
||||
required
|
||||
minLength={TEAM.NAME_MIN_LENGTH}
|
||||
maxLength={TEAM.NAME_MAX_LENGTH}
|
||||
defaultValue={team.name}
|
||||
/>
|
||||
<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="title" required>
|
||||
{t("team:forms.fields.teamTwitter")}
|
||||
</Label>
|
||||
<input
|
||||
name="twitter"
|
||||
required
|
||||
maxLength={TEAM.TWITTER_MAX_LENGTH}
|
||||
defaultValue={team.twitter}
|
||||
/>
|
||||
</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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export const handle: SendouRouteHandle = {
|
||||
export const loader = ({ params }: LoaderArgs) => {
|
||||
const { customUrl } = teamParamsSchema.parse(params);
|
||||
|
||||
const team = notFoundIfFalsy(findByIdentifier(customUrl.toLowerCase()));
|
||||
const team = notFoundIfFalsy(findByIdentifier(customUrl));
|
||||
|
||||
return { team };
|
||||
};
|
||||
|
||||
6
app/features/team/team-constants.ts
Normal file
6
app/features/team/team-constants.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const TEAM = {
|
||||
NAME_MAX_LENGTH: 64,
|
||||
NAME_MIN_LENGTH: 2,
|
||||
BIO_MAX_LENGTH: 2000,
|
||||
TWITTER_MAX_LENGTH: 50,
|
||||
};
|
||||
@@ -1,3 +1,17 @@
|
||||
import { z } from "zod";
|
||||
import { falsyToNull } from "~/utils/zod";
|
||||
import { TEAM } from "./team-constants";
|
||||
|
||||
export const teamParamsSchema = z.object({ customUrl: z.string() });
|
||||
|
||||
export const editTeamSchema = z.object({
|
||||
name: z.string().min(TEAM.NAME_MIN_LENGTH).max(TEAM.NAME_MAX_LENGTH),
|
||||
bio: z.preprocess(
|
||||
falsyToNull,
|
||||
z.string().max(TEAM.BIO_MAX_LENGTH).nullable()
|
||||
),
|
||||
twitter: z.preprocess(
|
||||
falsyToNull,
|
||||
z.string().max(TEAM.TWITTER_MAX_LENGTH).nullable()
|
||||
),
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { MemberRole } from "~/db/types";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists";
|
||||
|
||||
export interface DetailedTeam {
|
||||
id: number;
|
||||
name: string;
|
||||
bio?: string;
|
||||
twitter?: string;
|
||||
@@ -15,9 +16,11 @@ export interface DetailedTeam {
|
||||
}
|
||||
|
||||
export interface DetailedTeamMember {
|
||||
id: number;
|
||||
discordName: string;
|
||||
discordId: string;
|
||||
discordAvatar: string | null;
|
||||
isOwner: boolean;
|
||||
weapons: MainWeaponId[];
|
||||
role?: MemberRole;
|
||||
}
|
||||
|
||||
11
app/features/team/team-utils.ts
Normal file
11
app/features/team/team-utils.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { DetailedTeam } from "./team-types";
|
||||
|
||||
export function isTeamOwner({
|
||||
team,
|
||||
user,
|
||||
}: {
|
||||
team: DetailedTeam;
|
||||
user: { id: number };
|
||||
}) {
|
||||
return team.members.some((member) => member.isOwner && member.id === user.id);
|
||||
}
|
||||
@@ -100,6 +100,8 @@ export const userResultsEditHighlightsPage = (user: UserLinkArgs) =>
|
||||
export const userNewBuildPage = (user: UserLinkArgs) =>
|
||||
`${userBuildsPage(user)}/new`;
|
||||
|
||||
export const teamPage = (customUrl: string) => `/t/${customUrl}`;
|
||||
|
||||
export const authErrorUrl = (errorCode: AuthErrorCode) =>
|
||||
`/?authError=${errorCode}`;
|
||||
export const impersonateUrl = (idToLogInAs: number) =>
|
||||
|
||||
@@ -3,5 +3,9 @@
|
||||
"roles.FRONTLINE": "Frontline",
|
||||
"roles.SUPPORT": "Support",
|
||||
"roles.BACKLINE": "Backline",
|
||||
"roles.COACH": "Coach"
|
||||
"roles.COACH": "Coach",
|
||||
"forms.fields.teamTwitter": "Team Twitter",
|
||||
"forms.fields.bio": "Bio",
|
||||
"forms.info.name": "Note that if you change your team's name then someone else can claim the name and URL for their team",
|
||||
"forms.errors.duplicateName": "There is already a team with this name"
|
||||
}
|
||||
|
||||
@@ -21,7 +21,9 @@ module.exports = {
|
||||
route("/to/:id/teams", "features/tournament/routes/to.$id.teams.tsx");
|
||||
route("/to/:id/join", "features/tournament/routes/to.$id.join.tsx");
|
||||
});
|
||||
|
||||
route("/t/:customUrl", "features/team/routes/t.$customUrl.tsx");
|
||||
route("/t/:customUrl/edit", "features/team/routes/t.$customUrl.edit.tsx");
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user