sendou.ink/app/features/calendar/routes/calendar.$id.tsx
2025-12-29 19:21:11 +02:00

308 lines
8.0 KiB
TypeScript

import clsx from "clsx";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { Link, useLoaderData } from "react-router";
import { Avatar } from "~/components/Avatar";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Image } from "~/components/Image";
import { Main } from "~/components/Main";
import { MapPoolStages } from "~/components/MapPoolSelector";
import { Placement } from "~/components/Placement";
import { Section } from "~/components/Section";
import { Table } from "~/components/Table";
import { useUser } from "~/features/auth/core/user";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { databaseTimestampToDate } from "~/utils/dates";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
CALENDAR_PAGE,
calendarEditPage,
calendarEventPage,
calendarReportWinnersPage,
mapsPageWithMapPool,
navIconUrl,
resolveBaseUrl,
userPage,
} from "~/utils/urls";
import { metaTags, type SerializeFrom } from "../../../utils/remix";
import { action } from "../actions/calendar.$id.server";
import {
canDeleteCalendarEvent,
canEditCalendarEvent,
canReportCalendarEventWinners,
} from "../calendar-utils";
import { Tags } from "../components/Tags";
import { loader } from "../loaders/calendar.$id.server";
export { loader, action };
import "~/styles/calendar-event.css";
export const meta: MetaFunction = (args) => {
const data = args.data as SerializeFrom<typeof loader>;
if (!data) return [];
return metaTags({
title: data.event.name,
location: args.location,
description:
data.event.description ??
`Splatoon competitive event hosted on ${resolveBaseUrl(data.event.bracketUrl)}`,
});
};
export const handle: SendouRouteHandle = {
i18n: ["calendar", "game-misc"],
breadcrumb: ({ match }) => {
const data = match.data as SerializeFrom<typeof loader> | undefined;
if (!data) return [];
return [
{
imgPath: navIconUrl("calendar"),
href: CALENDAR_PAGE,
type: "IMAGE",
},
{
text: data.event.name,
href: calendarEventPage(data.event.eventId),
type: "TEXT",
},
];
},
};
export default function CalendarEventPage() {
const user = useUser();
const data = useLoaderData<typeof loader>();
const { t } = useTranslation(["common", "calendar"]);
const isMounted = useIsMounted();
const { formatDateTime } = useTimeFormat();
return (
<Main className="stack lg">
<section className="stack sm">
<div className="event__times">
{data.event.startTimes.map((startTime, i) => (
<React.Fragment key={startTime}>
<span
className={clsx("event__day", {
hidden: data.event.startTimes.length === 1,
})}
>
{t("calendar:day", {
number: i + 1,
})}
</span>
<time dateTime={databaseTimestampToDate(startTime).toISOString()}>
{isMounted
? formatDateTime(databaseTimestampToDate(startTime), {
hour: "numeric",
minute: "numeric",
day: "numeric",
month: "long",
weekday: "long",
year: "numeric",
})
: null}
</time>
</React.Fragment>
))}
</div>
<div className="stack md">
<div className="stack xs">
<h2>{data.event.name}</h2>
<Tags tags={data.event.tags} />
</div>
<div className="stack horizontal sm flex-wrap">
{data.event.discordUrl ? (
<LinkButton
to={data.event.discordUrl}
variant="outlined"
size="small"
isExternal
>
Discord
</LinkButton>
) : null}
<LinkButton
to={data.event.bracketUrl}
variant="outlined"
size="small"
isExternal
>
{resolveBaseUrl(data.event.bracketUrl)}
</LinkButton>
{canEditCalendarEvent({ user, event: data.event }) && (
<LinkButton
size="small"
to={calendarEditPage(data.event.eventId)}
>
{t("common:actions.edit")}
</LinkButton>
)}
{canReportCalendarEventWinners({
user,
event: data.event,
startTimes: data.event.startTimes,
}) && (
<LinkButton
size="small"
to={calendarReportWinnersPage(data.event.eventId)}
>
{t("calendar:actions.reportWinners")}
</LinkButton>
)}
</div>
</div>
</section>
<Results />
<MapPoolInfo />
<div className="stack md">
<Description />
{canDeleteCalendarEvent({
user,
startTime: databaseTimestampToDate(data.event.startTimes[0]),
event: data.event,
}) ? (
<FormWithConfirm
dialogHeading={t("calendar:actions.delete.confirm", {
name: data.event.name,
})}
>
<SendouButton
className="ml-auto"
size="small"
variant="minimal-destructive"
type="submit"
>
{t("calendar:actions.delete")}
</SendouButton>
</FormWithConfirm>
) : null}
</div>
</Main>
);
}
function Results() {
const { t } = useTranslation(["common", "calendar"]);
const data = useLoaderData<typeof loader>();
if (!data.results.length) return null;
const isTeamResults = data.results.some(
(result) => result.players.length > 1,
);
return (
<Section title={t("calendar:results")} className="event__results-section">
{data.event.participantCount && (
<div className="event__results-participant-count">
{isTeamResults
? t("calendar:participatedCount", {
count: data.event.participantCount,
})
: t("calendar:participatedPlayerCount", {
count: data.event.participantCount,
})}
</div>
)}
<Table>
<thead>
<tr>
<th>{t("calendar:forms.team.placing")}</th>
<th>{t("common:forms.name")}</th>
<th>{t("calendar:members")}</th>
</tr>
</thead>
<tbody>
{data.results.map((result, i) => (
<tr key={i}>
<td className="pl-4">
<Placement placement={result.placement} />
</td>
<td>{result.teamName}</td>
<td>
<ul className="event__results-players">
{result.players.map((player) => {
return (
<li
key={player.name ? player.name : player.id}
className="flex items-center"
>
{player.name ? (
player.name
) : (
// as any but we know it's a user since it doesn't have name
<Link
to={userPage(player as any)}
className="stack horizontal xs items-center"
>
<Avatar user={player as any} size="xxs" />{" "}
{player.username}
</Link>
)}
</li>
);
})}
</ul>
</td>
</tr>
))}
</tbody>
</Table>
</Section>
);
}
function MapPoolInfo() {
const { t } = useTranslation(["calendar"]);
const data = useLoaderData<typeof loader>();
if (!data.event.mapPool || data.event.mapPool.length === 0) return null;
const mapPool = new MapPool(data.event.mapPool);
return (
<Section title={t("calendar:forms.mapPool")}>
<div className="event__map-pool-section">
<MapPoolStages mapPool={mapPool} />
<LinkButton
className="event__create-map-list-link"
to={mapsPageWithMapPool(mapPool)}
variant="outlined"
size="small"
>
<Image alt="" path={navIconUrl("maps")} width={22} height={22} />
{t("calendar:createMapList")}
</LinkButton>
</div>
</Section>
);
}
function Description() {
const { t } = useTranslation();
const data = useLoaderData<typeof loader>();
return (
<Section title={t("forms.description")}>
<div className="stack sm">
<div className="event__author">
<Avatar user={data.event} size="xs" />
{data.event.username}
</div>
{data.event.description && (
<div className="whitespace-pre-wrap">{data.event.description}</div>
)}
</div>
</Section>
);
}