sendou.ink/app/features/associations/routes/associations.tsx
2026-03-21 15:19:32 +02:00

265 lines
6.9 KiB
TypeScript

import { Check, Clipboard, Trash } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link, Outlet, useFetcher, useLoaderData } from "react-router";
import { useCopyToClipboard } from "react-use";
import { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { action } from "~/features/associations/actions/associations.server";
import {
type AssociationsLoaderData,
loader,
} from "~/features/associations/loaders/associations.server";
import { useUser } from "~/features/auth/core/user";
import { useHasPermission } from "~/modules/permissions/hooks";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { associationsPage, userPage } from "~/utils/urls";
export { action, loader };
export const handle: SendouRouteHandle = {
i18n: "scrims",
};
export default function AssociationsPage() {
const data = useLoaderData<typeof loader>();
return (
<Main className="stack lg">
<Outlet />
<div className="stack sm">
<Header />
</div>
<JoinForm />
{data.associations.map((association) => (
<Association key={association.id} association={association} />
))}
</Main>
);
}
function Header() {
const { t } = useTranslation(["scrims"]);
return (
<div>
<h1 className="text-xl">{t("scrims:associations.title")}</h1>
<div className="text-sm text-lighter">
{t("scrims:associations.explanation")}
</div>
</div>
);
}
function JoinForm() {
const data = useLoaderData<typeof loader>();
const fetcher = useFetcher();
const { t } = useTranslation(["common", "scrims"]);
if (!data.toJoin) return null;
return (
<fetcher.Form method="post" className="stack horizontal md items-center">
<input type="hidden" name="inviteCode" value={data.toJoin.inviteCode} />
<Label spaced={false}>
{t("scrims:associations.join.title", {
name: data.toJoin.association.name,
})}
</Label>
<SubmitButton
size="small"
_action="JOIN_ASSOCIATION"
state={fetcher.state}
>
{t("common:actions.join")}
</SubmitButton>
</fetcher.Form>
);
}
function Association({
association,
}: {
association: AssociationsLoaderData["associations"][number];
}) {
const { t } = useTranslation(["common", "scrims"]);
const user = useUser();
const canManage = useHasPermission(association, "MANAGE");
return (
<section>
<div className="stack horizontal sm items-center justify-between">
<h2 className="text-lg"> {association.name}</h2>
{canManage ? (
<FormWithConfirm
dialogHeading={t("scrims:associations.delete.title", {
name: association.name,
})}
fields={[
["associationId", association.id],
["_action", "DELETE_ASSOCIATION"],
]}
>
<SendouButton
shape="square"
icon={<Trash className="small-icon" />}
className="small-text"
variant="minimal-destructive"
type="submit"
data-testid="delete-association"
/>
</FormWithConfirm>
) : null}
</div>
<div className="text-sm text-lighter">
{t("scrims:associations.admin", {
username: association.members?.find((m) => m.role === "ADMIN")
?.username,
})}
</div>
{!canManage ? (
<FormWithConfirm
dialogHeading={t("scrims:associations.leave.title", {
name: association.name,
})}
fields={[
["_action", "LEAVE_ASSOCIATION"],
["associationId", association.id],
]}
submitButtonText={t("scrims:associations.leave.action")}
>
<SendouButton
variant="minimal-destructive"
type="submit"
size="small"
className="my-2"
data-testid="leave-team-button"
>
{t("scrims:associations.leave.action")}
</SendouButton>
</FormWithConfirm>
) : null}
<div className="stack sm mt-4">
{association.members?.map((member) => (
<AssociationMember
key={member.id}
member={member}
associationId={association.id}
showControls={canManage && member.id !== user?.id}
/>
))}
</div>
{association.inviteCode ? (
<AssociationInviteCodeActions
associationId={association.id}
inviteCode={association.inviteCode}
/>
) : null}
</section>
);
}
function AssociationInviteCodeActions({
associationId,
inviteCode,
}: {
associationId: number;
inviteCode: string;
}) {
const { t } = useTranslation(["common", "scrims"]);
const [state, copyToClipboard] = useCopyToClipboard();
const [copySuccess, setCopySuccess] = React.useState(false);
const fetcher = useFetcher();
const id = React.useId();
React.useEffect(() => {
if (!state.value) return;
setCopySuccess(true);
const timeout = setTimeout(() => setCopySuccess(false), 2000);
return () => clearTimeout(timeout);
}, [state]);
const inviteLink = `https://sendou.ink${associationsPage(inviteCode)}`;
return (
<div className="mt-6">
<label htmlFor={id}>{t("scrims:associations.shareLink.title")}</label>
<div className="stack horizontal sm items-center">
<input type="text" value={inviteLink} readOnly id={id} />
<SendouButton
shape="square"
variant={copySuccess ? "outlined-success" : "outlined"}
onPress={() => copyToClipboard(inviteLink)}
icon={copySuccess ? <Check /> : <Clipboard />}
aria-label="Copy to clipboard"
/>
</div>
<fetcher.Form method="post">
<input type="hidden" name="associationId" value={associationId} />
<SubmitButton
variant="minimal-destructive"
size="small"
_action="REFRESH_INVITE_CODE"
state={fetcher.state}
>
{t("scrims:associations.shareLink.reset")}
</SubmitButton>
</fetcher.Form>
</div>
);
}
function AssociationMember({
member,
associationId,
showControls,
}: {
member: NonNullable<
AssociationsLoaderData["associations"][number]["members"]
>[number];
associationId: number;
showControls?: boolean;
}) {
const { t } = useTranslation(["common", "scrims"]);
return (
<div className="stack horizontal sm items-center justify-between">
<Link
to={userPage(member)}
className="text-main-forced stack horizontal sm"
>
<Avatar size="xxs" user={member} />
{member.username}
</Link>
{showControls ? (
<FormWithConfirm
dialogHeading={t("scrims:associations.removeMember.title", {
username: member.username,
})}
submitButtonText={t("common:actions.remove")}
fields={[
["userId", member.id],
["associationId", associationId],
["_action", "REMOVE_MEMBER"],
]}
>
<SendouButton
shape="square"
icon={<Trash className="small-icon" />}
className="small-text"
variant="minimal-destructive"
size="small"
type="submit"
/>
</FormWithConfirm>
) : null}
</div>
);
}