mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-27 21:55:15 -05:00
Report results UI
This commit is contained in:
2
TODO.md
2
TODO.md
@@ -22,6 +22,8 @@ Calendar
|
||||
|
||||
## Other
|
||||
|
||||
- [ ] User selector allow passing users from top level
|
||||
- [ ] Constants use CALENDAR_EVENT object
|
||||
- [ ] New unfriendly crash if omits dates
|
||||
- [x] badges inside tag?
|
||||
- [x] Tags selector can remove
|
||||
|
||||
@@ -15,6 +15,7 @@ interface ComboboxProps<T> {
|
||||
inputName: string;
|
||||
placeholder: string;
|
||||
className?: string;
|
||||
id?: string;
|
||||
isLoading?: boolean;
|
||||
onChange?: (selectedOption?: ComboboxOption<T>) => void;
|
||||
}
|
||||
@@ -25,6 +26,7 @@ export function Combobox<T extends Record<string, string | null | number>>({
|
||||
placeholder,
|
||||
onChange,
|
||||
className,
|
||||
id,
|
||||
isLoading = false,
|
||||
}: ComboboxProps<T>) {
|
||||
const [selectedOption, setSelectedOption] =
|
||||
@@ -63,6 +65,7 @@ export function Combobox<T extends Record<string, string | null | number>>({
|
||||
(option as Unpacked<typeof options>)?.label ?? ""
|
||||
}
|
||||
data-cy={`${inputName}-combobox-input`}
|
||||
id={id}
|
||||
/>
|
||||
<HeadlessCombobox.Options
|
||||
className={clsx("combobox-options", {
|
||||
@@ -100,9 +103,10 @@ export function UserCombobox({
|
||||
onChange,
|
||||
userIdsToOmit,
|
||||
className,
|
||||
id,
|
||||
}: Pick<
|
||||
ComboboxProps<Pick<UserWithPlusTier, "discordId" | "plusTier">>,
|
||||
"inputName" | "onChange" | "className"
|
||||
"inputName" | "onChange" | "className" | "id"
|
||||
> & { userIdsToOmit?: Set<number> }) {
|
||||
const fetcher = useFetcher<UsersLoaderData>();
|
||||
|
||||
@@ -135,6 +139,7 @@ export function UserCombobox({
|
||||
isLoading={isLoading}
|
||||
onChange={onChange}
|
||||
className={className}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,11 @@ export const CALENDAR_EVENT_TAGS = Object.keys(
|
||||
allTags
|
||||
) as Array<CalendarEventTag>;
|
||||
|
||||
export const CALENDAR_EVENT_RESULT = {
|
||||
MAX_PARTICIPANTS_COUNT: 1000,
|
||||
MAX_PLAYERS_LENGTH: 8,
|
||||
} as const;
|
||||
|
||||
export const PLUS_TIERS = [1, 2, 3];
|
||||
|
||||
export const PLUS_UPVOTE = 1;
|
||||
|
||||
@@ -1,5 +1,299 @@
|
||||
import type { ActionFunction, LoaderArgs } from "@remix-run/node";
|
||||
import { Form } from "@remix-run/react";
|
||||
import { z } from "zod";
|
||||
import { Label } from "~/components/Label";
|
||||
import { Main } from "~/components/Main";
|
||||
import { CALENDAR_EVENT_RESULT } from "~/constants";
|
||||
import { db } from "~/db";
|
||||
import { requireUser } from "~/modules/auth";
|
||||
import { canReportCalendarEventWinners } from "~/permissions";
|
||||
import { notFoundIfFalsy, validate } from "~/utils/remix";
|
||||
import { actualNumber, id } from "~/utils/zod";
|
||||
import * as React from "react";
|
||||
import type { User } from "~/db/types";
|
||||
import { Button } from "~/components/Button";
|
||||
import clsx from "clsx";
|
||||
import { UserCombobox } from "~/components/Combobox";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const formData = await request.formData();
|
||||
// eslint-disable-next-line no-console
|
||||
console.log({ formData });
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const handle = {
|
||||
i18n: "calendar",
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderArgs) => {
|
||||
const parsedParams = z
|
||||
.object({ id: z.preprocess(actualNumber, id) })
|
||||
.parse(params);
|
||||
const user = await requireUser(request);
|
||||
const event = notFoundIfFalsy(db.calendarEvents.findById(parsedParams.id));
|
||||
|
||||
validate(
|
||||
canReportCalendarEventWinners({
|
||||
user,
|
||||
event,
|
||||
startTimes: event.startTimes,
|
||||
}),
|
||||
401
|
||||
);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
interface TeamResults {
|
||||
teamName: string;
|
||||
placement: string;
|
||||
players: Array<
|
||||
| {
|
||||
id: User["id"];
|
||||
}
|
||||
| string
|
||||
>;
|
||||
}
|
||||
|
||||
export default function ReportWinnersPage() {
|
||||
return <Main>erport</Main>;
|
||||
return (
|
||||
<Main halfWidth>
|
||||
<Form method="post" className="stack smedium items-start">
|
||||
{/* xxx: use real name */}
|
||||
<h1 className="text-lg">Reporting results of TEST Tournament</h1>
|
||||
<ParticipantsCountInput />
|
||||
<FormMessage type="info">
|
||||
You choose how many results to report. It can be just the winning
|
||||
team, top 3 or whatever you decide.
|
||||
</FormMessage>
|
||||
<TeamInputs />
|
||||
<Button type="submit" className="mt-4">
|
||||
Submit
|
||||
</Button>
|
||||
</Form>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function ParticipantsCountInput() {
|
||||
// const { eventToEdit } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor="name" required>
|
||||
Participants count
|
||||
</Label>
|
||||
<input
|
||||
name="participantsCount"
|
||||
type="number"
|
||||
required
|
||||
min={1}
|
||||
max={CALENDAR_EVENT_RESULT.MAX_PARTICIPANTS_COUNT}
|
||||
// defaultValue={eventToEdit?.name}
|
||||
data-cy="participants-count-input"
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamInputs() {
|
||||
const [amountOfTeams, setAmountOfTeams] = React.useState(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}
|
||||
/>
|
||||
{!hidden && <hr className="w-full" />}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
onClick={() => setAmountOfTeams((amountOfTeams) => amountOfTeams + 1)}
|
||||
tiny
|
||||
>
|
||||
Add team
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const NEW_PLAYER = { id: 0 } as const;
|
||||
|
||||
function Team({
|
||||
onRemoveTeam,
|
||||
hidden,
|
||||
}: {
|
||||
onRemoveTeam?: () => void;
|
||||
hidden: boolean;
|
||||
}) {
|
||||
const teamNameId = React.useId();
|
||||
const placementId = React.useId();
|
||||
const [results, setResults] = React.useState<TeamResults>({
|
||||
teamName: "",
|
||||
// xxx: could use i + 1
|
||||
placement: "1",
|
||||
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 });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx("stack md items-start", { hidden })}>
|
||||
{!hidden && (
|
||||
<input type="hidden" name="team" value={JSON.stringify(results)} />
|
||||
)}
|
||||
<div className="stack horizontal md">
|
||||
<div>
|
||||
<Label htmlFor={teamNameId}>Team name</Label>
|
||||
<input
|
||||
id={teamNameId}
|
||||
value={results.teamName}
|
||||
onChange={handleTeamNameChange}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={placementId}>Placement</Label>
|
||||
<input
|
||||
id={placementId}
|
||||
value={results.placement}
|
||||
type="number"
|
||||
onChange={handlePlacementChange}
|
||||
min={1}
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Players
|
||||
players={results.players}
|
||||
setPlayers={(players) => setResults({ ...results, players })}
|
||||
/>
|
||||
{onRemoveTeam && (
|
||||
<Button
|
||||
onClick={onRemoveTeam}
|
||||
tiny
|
||||
variant="minimal-destructive"
|
||||
className="mt-4"
|
||||
>
|
||||
Remove team
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Players({
|
||||
players,
|
||||
setPlayers,
|
||||
}: {
|
||||
players: TeamResults["players"];
|
||||
setPlayers: (newPlayers: TeamResults["players"]) => void;
|
||||
}) {
|
||||
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">
|
||||
Player {i + 1}
|
||||
</label>
|
||||
<Button
|
||||
tiny
|
||||
variant="minimal"
|
||||
onClick={() => handlePlayerInputTypeChange(i)}
|
||||
>
|
||||
{asPlainInput ? "Add as user (recommended)" : "Add as text"}
|
||||
</Button>
|
||||
</div>
|
||||
{asPlainInput ? (
|
||||
<input
|
||||
id={formId}
|
||||
onChange={(e) => handleInputChange(i, e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<UserCombobox
|
||||
id={formId}
|
||||
inputName="old-user"
|
||||
onChange={(selected) =>
|
||||
handleInputChange(
|
||||
i,
|
||||
selected?.value ? Number(selected?.value) : NEW_PLAYER.id
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="stack horizontal sm mt-2">
|
||||
<Button
|
||||
tiny
|
||||
onClick={handleAddPlayer}
|
||||
disabled={players.length === CALENDAR_EVENT_RESULT.MAX_PLAYERS_LENGTH}
|
||||
variant="outlined"
|
||||
>
|
||||
Add player
|
||||
</Button>{" "}
|
||||
<Button
|
||||
tiny
|
||||
variant="destructive"
|
||||
onClick={handleRemovePlayer}
|
||||
disabled={players.length === 1}
|
||||
>
|
||||
Remove player
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -482,6 +482,10 @@ dialog::backdrop {
|
||||
gap: var(--s-4);
|
||||
}
|
||||
|
||||
.stack.smedium {
|
||||
gap: var(--s-6);
|
||||
}
|
||||
|
||||
.stack.lg {
|
||||
gap: var(--s-8);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
font-size: var(--fonts-xs);
|
||||
}
|
||||
|
||||
.text-lg {
|
||||
font-size: var(--fonts-lg);
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
@@ -30,6 +34,10 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.w-24 {
|
||||
width: var(--s-24);
|
||||
}
|
||||
|
||||
.mt-2 {
|
||||
margin-block-start: var(--s-2);
|
||||
}
|
||||
@@ -38,6 +46,14 @@
|
||||
margin-block-start: var(--s-4);
|
||||
}
|
||||
|
||||
.mb-0 {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
.mb-1 {
|
||||
margin-block-end: var(--s-1);
|
||||
}
|
||||
|
||||
.ml-auto {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user