diff --git a/app/components/FormErrors.tsx b/app/components/FormErrors.tsx index b3d920acd..3c4c367e7 100644 --- a/app/components/FormErrors.tsx +++ b/app/components/FormErrors.tsx @@ -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[] }>(); diff --git a/app/features/team/queries/edit.server.ts b/app/features/team/queries/edit.server.ts new file mode 100644 index 000000000..a38d88289 --- /dev/null +++ b/app/features/team/queries/edit.server.ts @@ -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) { + return stm.get({ + id, + name, + customUrl, + bio, + twitter, + }) as Team; +} diff --git a/app/features/team/queries/findByIdentifier.server.ts b/app/features/team/queries/findByIdentifier.server.ts index 71b2c86df..6aa5b6b47 100644 --- a/app/features/team/queries/findByIdentifier.server.ts +++ b/app/features/team/queries/findByIdentifier.server.ts @@ -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 & - Pick & { weapons: string } + Pick & + Pick & { 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, diff --git a/app/features/team/routes/t.$customUrl.edit.tsx b/app/features/team/routes/t.$customUrl.edit.tsx new file mode 100644 index 000000000..86c1a3332 --- /dev/null +++ b/app/features/team/routes/t.$customUrl.edit.tsx @@ -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 ( +
+
+ + + + + {t("common:actions.submit")} + + + +
+ ); +} + +function NameInput() { + const { t } = useTranslation(["common", "team"]); + const { team } = useLoaderData(); + + return ( +
+ + + {t("team:forms.info.name")} +
+ ); +} + +function TwitterInput() { + const { t } = useTranslation(["team"]); + const { team } = useLoaderData(); + + return ( +
+ + +
+ ); +} + +function BioTextarea() { + const { t } = useTranslation(["team"]); + const { team } = useLoaderData(); + const [value, setValue] = React.useState(team.bio ?? ""); + + return ( +
+ +