mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-30 15:17:43 -05:00
Team: Leave feature
This commit is contained in:
@@ -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<HTMLFormElement>(null);
|
||||
const id = React.useId();
|
||||
@@ -34,9 +38,9 @@ export function FormWithConfirm({
|
||||
<h2 className="text-sm">{dialogHeading}</h2>
|
||||
<div className="stack horizontal md justify-center">
|
||||
<Button form={id} variant="destructive" type="submit">
|
||||
Delete
|
||||
{deleteButtonText ?? t("common:actions.delete")}
|
||||
</Button>
|
||||
<Button onClick={closeDialog}>Cancel</Button>
|
||||
<Button onClick={closeDialog}>{t("common:actions.cancel")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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<Team, "id" | "name" | "twitter" | "bio"> & {
|
||||
| (Pick<Team, "id" | "name" | "twitter" | "bio" | "customUrl"> & {
|
||||
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,
|
||||
|
||||
18
app/features/team/queries/leaveTeam.server.ts
Normal file
18
app/features/team/queries/leaveTeam.server.ts
Normal file
@@ -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 });
|
||||
}
|
||||
5
app/features/team/routes/t.$customUrl.roster.tsx
Normal file
5
app/features/team/routes/t.$customUrl.roster.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Main } from "~/components/Main";
|
||||
|
||||
export default function ManageTeamRosterPage() {
|
||||
return <Main>manage it!</Main>;
|
||||
}
|
||||
@@ -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() {
|
||||
{/* <InfoBadges /> */}
|
||||
</div>
|
||||
<MobileTeamNameCountry />
|
||||
<ActionButtons />
|
||||
{team.results ? <ResultsBanner results={team.results} /> : null}
|
||||
{team.bio ? <article>{team.bio}</article> : null}
|
||||
<div className="stack lg">
|
||||
@@ -152,6 +172,51 @@ function MobileTeamNameCountry() {
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButtons() {
|
||||
const { t } = useTranslation(["team"]);
|
||||
const user = useUser();
|
||||
const { team } = useLoaderData<typeof loader>();
|
||||
|
||||
if (!isTeamMember({ user, team })) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="team__action-buttons">
|
||||
{!isTeamOwner({ user, team }) ? (
|
||||
<FormWithConfirm
|
||||
dialogHeading={t("team:leaveTeam.header", { teamName: team.name })}
|
||||
deleteButtonText={t("team:actionButtons.leaveTeam.confirm")}
|
||||
>
|
||||
<Button size="tiny" variant="destructive">
|
||||
{t("team:actionButtons.leaveTeam")}
|
||||
</Button>
|
||||
</FormWithConfirm>
|
||||
) : null}
|
||||
{isTeamOwner({ user, team }) ? (
|
||||
<LinkButton
|
||||
size="tiny"
|
||||
to={manageTeamRosterPage(team.customUrl)}
|
||||
variant="outlined"
|
||||
prefetch="intent"
|
||||
>
|
||||
{t("team:actionButtons.manageRoster")}
|
||||
</LinkButton>
|
||||
) : null}
|
||||
{isTeamOwner({ user, team }) ? (
|
||||
<LinkButton
|
||||
size="tiny"
|
||||
to={editTeamPage(team.customUrl)}
|
||||
variant="outlined"
|
||||
prefetch="intent"
|
||||
>
|
||||
{t("team:actionButtons.editTeam")}
|
||||
</LinkButton>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultsBanner({ results }: { results: TeamResultPeek }) {
|
||||
return (
|
||||
<Link className="team__results" to="results">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user