diff --git a/app/components/FormWithConfirm.tsx b/app/components/FormWithConfirm.tsx index 8631e566d..e02b46d79 100644 --- a/app/components/FormWithConfirm.tsx +++ b/app/components/FormWithConfirm.tsx @@ -1,6 +1,7 @@ import { Form } from "@remix-run/react"; import React from "react"; import invariant from "tiny-invariant"; +import { useTranslation } from "~/hooks/useTranslation"; import { Button } from "./Button"; import { Dialog } from "./Dialog"; @@ -8,11 +9,14 @@ export function FormWithConfirm({ fields, children, dialogHeading, + deleteButtonText, }: { fields?: [name: string, value: string | number][]; children: React.ReactNode; dialogHeading: string; + deleteButtonText?: string; }) { + const { t } = useTranslation(["common"]); const [dialogOpen, setDialogOpen] = React.useState(false); const formRef = React.useRef(null); const id = React.useId(); @@ -34,9 +38,9 @@ export function FormWithConfirm({

{dialogHeading}

- +
diff --git a/app/db/seed.ts b/app/db/seed.ts index 18bf1e879..47c3a0078 100644 --- a/app/db/seed.ts +++ b/app/db/seed.ts @@ -69,7 +69,7 @@ export function seed() { function wipeDB() { const tablesToDelete = [ "UnvalidatedUserSubmittedImage", - "TeamMember", + "AllTeamMember", "AllTeam", "Build", "TournamentTeamMember", @@ -909,7 +909,7 @@ function detailedTeam() { sql .prepare( /*sql*/ ` - insert into "TeamMember" ("teamId", "userId", "role", "isOwner", "leftAt") + insert into "AllTeamMember" ("teamId", "userId", "role", "isOwner", "leftAt") values ( 1, ${userId}, diff --git a/app/features/team/queries/findByIdentifier.server.ts b/app/features/team/queries/findByIdentifier.server.ts index 04b98772d..75e836cb8 100644 --- a/app/features/team/queries/findByIdentifier.server.ts +++ b/app/features/team/queries/findByIdentifier.server.ts @@ -9,6 +9,7 @@ const teamStm = sql.prepare(/*sql*/ ` "t"."name", "t"."twitter", "t"."bio", + "t"."customUrl", "ia"."url" as "avatarSrc", "ib"."url" as "bannerSrc", json_group_array("User"."country") as "countries" @@ -38,7 +39,7 @@ const membersStm = sql.prepare(/*sql*/ ` `); type TeamRow = - | (Pick & { + | (Pick & { avatarSrc: string; bannerSrc: string; countries: string; @@ -60,6 +61,7 @@ export function findByIdentifier(customUrl: string): DetailedTeam | null { return { id: team.id, name: team.name, + customUrl: team.customUrl, twitter: team.twitter ?? undefined, bio: team.bio ?? undefined, avatarSrc: team.avatarSrc, diff --git a/app/features/team/queries/leaveTeam.server.ts b/app/features/team/queries/leaveTeam.server.ts new file mode 100644 index 000000000..218aa7b7b --- /dev/null +++ b/app/features/team/queries/leaveTeam.server.ts @@ -0,0 +1,18 @@ +import { sql } from "~/db/sql"; + +const stm = sql.prepare(/* sql */ ` + update "AllTeamMember" + set "leftAt" = strftime('%s', 'now') + where "teamId" = @teamId + and "userId" = @userId +`); + +export function leaveTeam({ + teamId, + userId, +}: { + teamId: number; + userId: number; +}) { + stm.run({ teamId, userId }); +} diff --git a/app/features/team/routes/t.$customUrl.roster.tsx b/app/features/team/routes/t.$customUrl.roster.tsx new file mode 100644 index 000000000..7ad7909ca --- /dev/null +++ b/app/features/team/routes/t.$customUrl.roster.tsx @@ -0,0 +1,5 @@ +import { Main } from "~/components/Main"; + +export default function ManageTeamRosterPage() { + return
manage it!
; +} diff --git a/app/features/team/routes/t.$customUrl.tsx b/app/features/team/routes/t.$customUrl.tsx index 100311a24..7f6fc868a 100644 --- a/app/features/team/routes/t.$customUrl.tsx +++ b/app/features/team/routes/t.$customUrl.tsx @@ -1,4 +1,5 @@ import type { + ActionFunction, LinksFunction, LoaderArgs, MetaFunction, @@ -8,18 +9,23 @@ import { Link, useLoaderData } from "@remix-run/react"; import clsx from "clsx"; import React from "react"; import { Avatar } from "~/components/Avatar"; +import { Button, LinkButton } from "~/components/Button"; import { Flag } from "~/components/Flag"; +import { FormWithConfirm } from "~/components/FormWithConfirm"; import { WeaponImage } from "~/components/Image"; import { Main } from "~/components/Main"; import { Placement } from "~/components/Placement"; import { useTranslation } from "~/hooks/useTranslation"; -import type { SendouRouteHandle } from "~/utils/remix"; +import { requireUser, useUser } from "~/modules/auth"; +import { type SendouRouteHandle, validate } from "~/utils/remix"; import { notFoundIfFalsy } from "~/utils/remix"; import { makeTitle } from "~/utils/strings"; -import { userPage } from "~/utils/urls"; +import { editTeamPage, manageTeamRosterPage, userPage } from "~/utils/urls"; import { findByIdentifier } from "../queries/findByIdentifier.server"; +import { leaveTeam } from "../queries/leaveTeam.server"; import { teamParamsSchema } from "../team-schemas.server"; import type { DetailedTeamMember, TeamResultPeek } from "../team-types"; +import { isTeamMember, isTeamOwner } from "../team-utils"; import styles from "../team.css"; export const meta: MetaFunction = ({ @@ -39,6 +45,19 @@ export const links: LinksFunction = () => { return [{ rel: "stylesheet", href: styles }]; }; +export const action: ActionFunction = async ({ request, params }) => { + const user = await requireUser(request); + + const { customUrl } = teamParamsSchema.parse(params); + const team = notFoundIfFalsy(findByIdentifier(customUrl)); + + validate(isTeamMember({ user, team }) && !isTeamOwner({ user, team })); + + leaveTeam({ userId: user.id, teamId: team.id }); + + return null; +}; + export const handle: SendouRouteHandle = { i18n: ["team"], // breadcrumb: () => ({ @@ -66,6 +85,7 @@ export default function TeamPage() { {/* */} + {team.results ? : null} {team.bio ?
{team.bio}
: null}
@@ -152,6 +172,51 @@ function MobileTeamNameCountry() { ); } +function ActionButtons() { + const { t } = useTranslation(["team"]); + const user = useUser(); + const { team } = useLoaderData(); + + if (!isTeamMember({ user, team })) { + return null; + } + + return ( +
+ {!isTeamOwner({ user, team }) ? ( + + + + ) : null} + {isTeamOwner({ user, team }) ? ( + + {t("team:actionButtons.manageRoster")} + + ) : null} + {isTeamOwner({ user, team }) ? ( + + {t("team:actionButtons.editTeam")} + + ) : null} +
+ ); +} + function ResultsBanner({ results }: { results: TeamResultPeek }) { return ( diff --git a/app/features/team/team-types.ts b/app/features/team/team-types.ts index b15be08d4..e284c32a5 100644 --- a/app/features/team/team-types.ts +++ b/app/features/team/team-types.ts @@ -3,6 +3,7 @@ import type { MainWeaponId } from "~/modules/in-game-lists"; export interface DetailedTeam { id: number; + customUrl: string; name: string; bio?: string; twitter?: string; diff --git a/app/features/team/team-utils.ts b/app/features/team/team-utils.ts index d057c4d74..5f693e02b 100644 --- a/app/features/team/team-utils.ts +++ b/app/features/team/team-utils.ts @@ -5,7 +5,21 @@ export function isTeamOwner({ user, }: { team: DetailedTeam; - user: { id: number }; + user?: { id: number }; }) { + if (!user) return false; + return team.members.some((member) => member.isOwner && member.id === user.id); } + +export function isTeamMember({ + team, + user, +}: { + team: DetailedTeam; + user?: { id: number }; +}) { + if (!user) return false; + + return team.members.some((member) => member.id === user.id); +} diff --git a/app/features/team/team.css b/app/features/team/team.css index fa9fd2699..e8d16f679 100644 --- a/app/features/team/team.css +++ b/app/features/team/team.css @@ -90,6 +90,12 @@ height: 1.5rem; } +.team__action-buttons { + display: flex; + justify-content: flex-end; + gap: var(--s-2); +} + .team__results { background-color: var(--bg-lightest); max-width: 32rem; diff --git a/app/utils/urls.ts b/app/utils/urls.ts index df1602f9f..47806fab1 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -101,6 +101,10 @@ export const userNewBuildPage = (user: UserLinkArgs) => `${userBuildsPage(user)}/new`; export const teamPage = (customUrl: string) => `/t/${customUrl}`; +export const editTeamPage = (customUrl: string) => + `${teamPage(customUrl)}/edit`; +export const manageTeamRosterPage = (customUrl: string) => + `${teamPage(customUrl)}/roster`; export const authErrorUrl = (errorCode: AuthErrorCode) => `/?authError=${errorCode}`; diff --git a/migrations/015-teams.js b/migrations/015-teams.js index 29ac9100f..6f99bee05 100644 --- a/migrations/015-teams.js +++ b/migrations/015-teams.js @@ -47,7 +47,7 @@ module.exports.up = function (db) { db.prepare( /*sql*/ ` - create table "TeamMember" ( + create table "AllTeamMember" ( "teamId" integer not null, "userId" integer not null, "role" text, @@ -60,4 +60,12 @@ module.exports.up = function (db) { ) strict ` ).run(); + + db.prepare( + /*sql*/ ` + create view "TeamMember" + as + select * from "AllTeamMember" where "leftAt" is null + ` + ).run(); }; diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 7a666ee9a..3f7a42f55 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -43,6 +43,7 @@ "actions.loadMore": "Load more", "actions.copyToClipboard": "Copy to clipboard", "actions.close": "Close", + "actions.cancel": "Cancel", "actions.loading": "Loading...", "actions.clear": "Clear", "actions.selectAll": "Select All", diff --git a/public/locales/en/team.json b/public/locales/en/team.json index c7d38b350..5bcf2d5df 100644 --- a/public/locales/en/team.json +++ b/public/locales/en/team.json @@ -1,4 +1,9 @@ { + "actionButtons.leaveTeam": "Leave Team", + "leaveTeam.header": "Are you sure you want to leave {{teamName}}?", + "actionButtons.leaveTeam.confirm": "Leave", + "actionButtons.editTeam": "Edit Team", + "actionButtons.manageRoster": "Manage Roster", "roles.CAPTAIN": "Captain", "roles.FRONTLINE": "Frontline", "roles.SUPPORT": "Support", diff --git a/remix.config.js b/remix.config.js index 94fdf8248..70f74d3ff 100644 --- a/remix.config.js +++ b/remix.config.js @@ -24,6 +24,10 @@ module.exports = { route("/t/:customUrl", "features/team/routes/t.$customUrl.tsx"); route("/t/:customUrl/edit", "features/team/routes/t.$customUrl.edit.tsx"); + route( + "/t/:customUrl/roster", + "features/team/routes/t.$customUrl.roster.tsx" + ); }); }, };