mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
313 lines
7.9 KiB
TypeScript
313 lines
7.9 KiB
TypeScript
import clsx from "clsx";
|
|
import * as React from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Form, useLoaderData } from "react-router";
|
|
import { SendouButton } from "~/components/elements/Button";
|
|
import { UserSearch } from "~/components/elements/UserSearch";
|
|
import { FormErrors } from "~/components/FormErrors";
|
|
import { FormMessage } from "~/components/FormMessage";
|
|
import { Label } from "~/components/Label";
|
|
import { Main } from "~/components/Main";
|
|
import type { SerializeFrom } from "~/utils/remix";
|
|
import type { SendouRouteHandle } from "~/utils/remix.server";
|
|
import type { Unpacked } from "~/utils/types";
|
|
import { action } from "../actions/calendar.$id.report-winners.server";
|
|
import { CALENDAR_EVENT_RESULT } from "../calendar-constants";
|
|
import { loader } from "../loaders/calendar.$id.report-winners.server";
|
|
|
|
export { action, loader };
|
|
|
|
export const handle: SendouRouteHandle = {
|
|
i18n: "calendar",
|
|
};
|
|
|
|
export default function ReportWinnersPage() {
|
|
const { t } = useTranslation(["common", "calendar"]);
|
|
const data = useLoaderData<typeof loader>();
|
|
|
|
return (
|
|
<Main halfWidth>
|
|
<Form method="post" className="stack md-plus items-start">
|
|
<h1 className="text-lg">
|
|
{t("calendar:forms.reportResultsHeader", { eventName: data.name })}
|
|
</h1>
|
|
<ParticipantsCountInput />
|
|
<FormMessage type="info">
|
|
{t("calendar:forms.reportResultsInfo")}
|
|
</FormMessage>
|
|
<TeamInputs />
|
|
<SendouButton type="submit" className="mt-4">
|
|
{t("common:actions.submit")}
|
|
</SendouButton>
|
|
<FormErrors namespace="calendar" />
|
|
</Form>
|
|
</Main>
|
|
);
|
|
}
|
|
|
|
function ParticipantsCountInput() {
|
|
const { t } = useTranslation("calendar");
|
|
const data = useLoaderData<typeof loader>();
|
|
|
|
return (
|
|
<div>
|
|
<Label htmlFor="name" required>
|
|
{t("forms.participantCount")}
|
|
</Label>
|
|
<input
|
|
name="participantCount"
|
|
type="number"
|
|
required
|
|
min={1}
|
|
max={CALENDAR_EVENT_RESULT.MAX_PARTICIPANTS_COUNT}
|
|
defaultValue={data.participantCount ?? undefined}
|
|
className="w-24"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TeamInputs() {
|
|
const { t } = useTranslation("calendar");
|
|
const data = useLoaderData<typeof loader>();
|
|
const [amountOfTeams, setAmountOfTeams] = React.useState(
|
|
Math.max(data.winners.length, 1),
|
|
);
|
|
|
|
const handleTeamDelete = () => {
|
|
setAmountOfTeams(amountOfTeams - 1);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<hr className="w-full" />
|
|
{new Array(amountOfTeams + 1).fill(null).map((_, i) => {
|
|
// last team is hidden so we can save its state even if user removes a filled team
|
|
const hidden = i === amountOfTeams;
|
|
|
|
return (
|
|
<React.Fragment key={i}>
|
|
<Team
|
|
onRemoveTeam={
|
|
i === amountOfTeams - 1 && amountOfTeams > 1
|
|
? handleTeamDelete
|
|
: undefined
|
|
}
|
|
hidden={hidden}
|
|
initialPlacement={String(i + 1)}
|
|
initialValues={data.winners[i]}
|
|
/>
|
|
{!hidden && <hr className="w-full" />}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
<SendouButton
|
|
onPress={() => setAmountOfTeams((amountOfTeams) => amountOfTeams + 1)}
|
|
size="small"
|
|
>
|
|
{t("forms.team.add")}
|
|
</SendouButton>
|
|
</>
|
|
);
|
|
}
|
|
|
|
const NEW_PLAYER = { id: 0 } as const;
|
|
|
|
interface TeamResults {
|
|
teamName: string;
|
|
placement: string;
|
|
players: Array<
|
|
| {
|
|
id: number;
|
|
}
|
|
| string
|
|
>;
|
|
}
|
|
|
|
function Team({
|
|
onRemoveTeam,
|
|
hidden,
|
|
initialPlacement,
|
|
initialValues,
|
|
}: {
|
|
onRemoveTeam?: () => void;
|
|
hidden: boolean;
|
|
initialPlacement: string;
|
|
initialValues?: Unpacked<SerializeFrom<typeof loader>["winners"]>;
|
|
}) {
|
|
const { t } = useTranslation("calendar");
|
|
const teamNameId = React.useId();
|
|
const placementId = React.useId();
|
|
|
|
const [results, setResults] = React.useState<TeamResults>({
|
|
teamName: initialValues?.teamName ?? "",
|
|
placement: String(initialValues?.placement ?? initialPlacement),
|
|
players: initialValues?.players
|
|
? (initialValues.players.map((player) =>
|
|
player.name ? player.name : player,
|
|
) as TeamResults["players"])
|
|
: [NEW_PLAYER, NEW_PLAYER, NEW_PLAYER, NEW_PLAYER],
|
|
});
|
|
|
|
const handleTeamNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
setResults({ ...results, teamName: e.target.value });
|
|
};
|
|
|
|
const handlePlacementChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
setResults({ ...results, placement: e.target.value });
|
|
};
|
|
|
|
if (hidden) return null;
|
|
|
|
return (
|
|
<div className={clsx("stack md items-start")}>
|
|
<input
|
|
type="hidden"
|
|
name="team"
|
|
value={JSON.stringify({
|
|
...results,
|
|
players: results.players.filter(
|
|
(player) =>
|
|
(typeof player === "string" && player !== "") ||
|
|
(typeof player === "object" && player.id !== 0),
|
|
),
|
|
})}
|
|
/>
|
|
<div className="stack horizontal md flex-wrap">
|
|
<div>
|
|
<Label htmlFor={teamNameId}>{t("forms.team.name")}</Label>
|
|
<input
|
|
id={teamNameId}
|
|
value={results.teamName}
|
|
onChange={handleTeamNameChange}
|
|
required
|
|
maxLength={CALENDAR_EVENT_RESULT.MAX_TEAM_NAME_LENGTH}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor={placementId}>{t("forms.team.placing")}</Label>
|
|
<input
|
|
id={placementId}
|
|
value={results.placement}
|
|
type="number"
|
|
onChange={handlePlacementChange}
|
|
required
|
|
max={CALENDAR_EVENT_RESULT.MAX_TEAM_PLACEMENT}
|
|
className="w-24"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<Players
|
|
players={results.players}
|
|
setPlayers={(players) => setResults({ ...results, players })}
|
|
/>
|
|
{onRemoveTeam && (
|
|
<SendouButton
|
|
onPress={onRemoveTeam}
|
|
size="small"
|
|
variant="minimal-destructive"
|
|
className="mt-4"
|
|
>
|
|
{t("forms.team.remove")}
|
|
</SendouButton>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Players({
|
|
players,
|
|
setPlayers,
|
|
}: {
|
|
players: TeamResults["players"];
|
|
setPlayers: (newPlayers: TeamResults["players"]) => void;
|
|
}) {
|
|
const { t } = useTranslation("calendar");
|
|
const handleAddPlayer = () => {
|
|
setPlayers([...players, NEW_PLAYER]);
|
|
};
|
|
|
|
const handleRemovePlayer = () => {
|
|
setPlayers(players.slice(0, -1));
|
|
};
|
|
|
|
const handlePlayerInputTypeChange = (index: number) => {
|
|
const newPlayers = [...players];
|
|
newPlayers[index] = typeof newPlayers[index] === "string" ? NEW_PLAYER : "";
|
|
setPlayers(newPlayers);
|
|
};
|
|
|
|
const handleInputChange = (index: number, newValue: string | number) => {
|
|
const newPlayers = [...players];
|
|
newPlayers[index] =
|
|
typeof newValue === "string" ? newValue : { id: newValue };
|
|
setPlayers(newPlayers);
|
|
};
|
|
|
|
return (
|
|
<div className="stack md">
|
|
{players.map((player, i) => {
|
|
const formId = `player-${i + 1}`;
|
|
const asPlainInput = typeof player === "string";
|
|
|
|
return (
|
|
<div key={i}>
|
|
<div className="stack horizontal md items-center mb-1">
|
|
<label htmlFor={formId} className="mb-0">
|
|
{t("forms.team.player.header", { number: i + 1 })}
|
|
</label>
|
|
<SendouButton
|
|
size="small"
|
|
variant="minimal"
|
|
onPress={() => handlePlayerInputTypeChange(i)}
|
|
>
|
|
{asPlainInput
|
|
? t("forms.team.player.addAsUser")
|
|
: t("forms.team.player.addAsText")}
|
|
</SendouButton>
|
|
</div>
|
|
{asPlainInput ? (
|
|
<input
|
|
id={formId}
|
|
value={player}
|
|
onChange={(e) => handleInputChange(i, e.target.value)}
|
|
max={CALENDAR_EVENT_RESULT.MAX_PLAYER_NAME_LENGTH}
|
|
/>
|
|
) : (
|
|
<UserSearch
|
|
id={formId}
|
|
name="team-player"
|
|
initialUserId={player.id}
|
|
onChange={(newUser) => {
|
|
if (!newUser) return;
|
|
handleInputChange(i, newUser.id);
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
<div className="stack horizontal sm mt-2">
|
|
<SendouButton
|
|
size="small"
|
|
onPress={handleAddPlayer}
|
|
isDisabled={
|
|
players.length === CALENDAR_EVENT_RESULT.MAX_PLAYERS_LENGTH
|
|
}
|
|
variant="outlined"
|
|
>
|
|
{t("forms.team.player.add")}
|
|
</SendouButton>{" "}
|
|
<SendouButton
|
|
size="small"
|
|
variant="destructive"
|
|
onPress={handleRemovePlayer}
|
|
isDisabled={players.length === 1}
|
|
>
|
|
{t("forms.team.player.remove")}
|
|
</SendouButton>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|