import { useLoaderData } from "@remix-run/react"; import { Button, Container, Heading, Input, Link, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, NumberDecrementStepper, NumberIncrementStepper, NumberInput, NumberInputField, NumberInputStepper, Table, TableCaption, TableContainer, Tbody, Td, Text, Th, Thead, Tr, useDisclosure, useToast, } from "@chakra-ui/react"; import { type FormEvent, useState } from "react"; export async function loader({ context }: { context: RequestContext }) { if (!context.data.current_user) throw new Response(null, { status: 401, }); if (![1 << 4, 1 << 12].find((p) => context.data.current_user.permissions & p)) throw new Response(null, { status: 403, }); const etData = await context.env.D1.prepare( "SELECT id, name, points, roblox_id FROM et_members;", ).all(); if (etData.error) throw new Response(null, { status: 500, }); const now = new Date(); const members = etData.results as { [k: string]: any }[]; const currentMonthEvents = await context.env.D1.prepare( "SELECT answered_at, created_by, performed_at, reached_minimum_player_count, type FROM events WHERE year = ? AND month = ?;", ) .bind(now.getUTCFullYear(), now.getUTCMonth() + 1) .all(); if (!currentMonthEvents.error) { for (const event of currentMonthEvents.results as { [k: string]: any }[]) { const memberIdx = members.findIndex((m) => m.id === event.created_by); if (memberIdx === -1) continue; if (event.performed_at) members[memberIdx].points += 10; if (event.type === "gamenight" && event.reached_minimum_player_count) members[memberIdx].points += 10; if ( event.type === "rotw" && event.answered_at - event.performed_at >= 86400000 ) members[memberIdx].points += 10; if (!event.performed_at && event.day < now.getUTCDate()) members[memberIdx].points -= 5; } } return { members } as { members: { [k: string]: any }[]; }; } export default function () { const toast = useToast(); async function removeMember(id: string) { const removeResp = await fetch("/api/events-team/team-members/user", { body: JSON.stringify({ id }), headers: { "content-type": "application/json", }, method: "DELETE", }); if (!removeResp.ok) { toast({ description: "Failed to remove member, try again later", status: "error", title: "Oops", }); return; } toast({ description: "The member was removed from the roster", status: "success", title: "Member Removed", }); setMemberData(memberData.filter((member) => member.id !== id)); } async function addMember() { const addResp = await fetch("/api/events-team/team-members/user", { body: JSON.stringify({ id: addingMemberId, name: addingMemberName, roblox_username: addingMemberRoblox, }), headers: { "content-type": "application/json", }, method: "POST", }); if (!addResp.ok) { toast({ description: "Failed to add member, try again later", status: "error", title: "Oops", }); return; } toast({ description: `Member ${addingMemberName} was added to the roster`, status: "success", title: "Member Added", }); location.reload(); } const data = useLoaderData<typeof loader>(); const [realtimePoints, setRealtimePoints] = useState(0); const [currentModalMember, setModalMember] = useState(""); const [currentDelMember, setDelMember] = useState({ id: "", name: "" }); const [memberData, setMemberData] = useState(data.members); const [addingMemberId, setAddingMemberId] = useState(""); const [addingMemberName, setAddingMemberName] = useState(""); const [addingMemberRoblox, setAddingMemberRoblox] = useState(""); const { isOpen, onClose, onOpen } = useDisclosure(); const { isOpen: isDelConfirmOpen, onClose: closeDelConfirm, onOpen: openDelConfirm, } = useDisclosure(); const { isOpen: isAddMemberOpen, onClose: closeAddMember, onOpen: openAddMember, } = useDisclosure(); const { isOpen: isNameChangeOpen, onClose: closeNameChange, onOpen: openNameChange, } = useDisclosure(); const { isOpen: isChangeRobloxOpen, onClose: closeChangeRoblox, onOpen: openChangeRoblox, } = useDisclosure(); function validateRobloxName(e: FormEvent<HTMLInputElement>) { const data = (e.target as HTMLInputElement).value as string; if (!data) return; if ( data.match(/\W/) || data.length > 20 || // Need Number pseudo-constructor since matches might be null (data.match(/_/g)?.length || 0) > 1 || data.startsWith("_") ) e.preventDefault(); } async function updatePoints(id: string, points: number) { const updateResp = await fetch(`/api/events-team/points/${id}`, { body: JSON.stringify({ points }), headers: { "content-type": "application/json", }, method: "POST", }); if (!updateResp.ok) { toast({ description: "Failed to update points", status: "error", title: "Oops!", }); return; } toast({ description: `Point count changed to ${points}`, status: "success", title: "Points updated", }); const newMemberData = memberData; newMemberData[memberData.findIndex((m) => m.id === id)].points = points; setMemberData([...newMemberData]); onClose(); } return ( <Container maxW="container.lg"> <Modal isOpen={isChangeRobloxOpen} onClose={closeChangeRoblox}> <ModalOverlay /> <ModalContent> <ModalHeader>Change Roblox User</ModalHeader> <ModalCloseButton /> <ModalBody> <Heading mb="8px" size="xs"> New Roblox Username </Heading> <Input maxLength={20} onBeforeInput={validateRobloxName} onChange={(e) => setAddingMemberRoblox(e.target.value)} placeholder="builderman" /> </ModalBody> <ModalFooter> <Button onClick={() => { setAddingMemberRoblox(""); closeChangeRoblox(); }} > Cancel </Button> <Button colorScheme="blue" ml="8px" onClick={async () => { const changeResp = await fetch( "/api/events-team/team-members/user", { body: JSON.stringify({ id: currentModalMember, roblox_username: addingMemberRoblox, }), headers: { "content-type": "application/json", }, method: "PATCH", }, ); if (!changeResp.ok) { let errorMsg = "Unknown error"; try { errorMsg = ((await changeResp.json()) as { error: string }) .error; } catch {} toast({ description: errorMsg, status: "error", title: "Failed to change", }); return; } toast({ description: "Roblox information updated", status: "success", title: "Change successful", }); const newMemberData = memberData; newMemberData[ memberData.findIndex((m) => m.id === currentModalMember) ].roblox_id = ( (await changeResp.json()) as { name: string; roblox_id: number; } ).roblox_id; setMemberData([...newMemberData]); closeChangeRoblox(); setModalMember(""); setAddingMemberRoblox(""); }} > Change </Button> </ModalFooter> </ModalContent> </Modal> <Modal isOpen={isNameChangeOpen} onClose={closeNameChange}> <ModalOverlay /> <ModalContent> <ModalHeader>Change Name</ModalHeader> <ModalCloseButton /> <ModalBody> <Input maxLength={64} onChange={(e) => setAddingMemberName(e.target.value)} placeholder="New name" /> </ModalBody> <ModalFooter> <Button onClick={() => { setAddingMemberName(""); closeNameChange(); }} > Cancel </Button> <Button colorScheme="blue" ml="8px" onClick={async () => { const nameUpdateResp = await fetch( "/api/events-team/team-members/user", { body: JSON.stringify({ id: currentModalMember, name: addingMemberName, }), headers: { "content-type": "application/json", }, method: "PATCH", }, ); const newName = addingMemberName; closeNameChange(); setAddingMemberName(""); if (!nameUpdateResp.ok) { let errorMsg = "Unknown error"; try { errorMsg = ( (await nameUpdateResp.json()) as { error: string } ).error; } catch {} toast({ description: errorMsg, status: "error", title: "Error", }); return; } toast({ description: `Name changed to ${newName}`, status: "success", title: "Name changed", }); const newMemberData = memberData; newMemberData[ memberData.findIndex((m) => m.id === currentModalMember) ].name = newName; setMemberData([...newMemberData]); }} > Update Name </Button> </ModalFooter> </ModalContent> </Modal> <Modal isOpen={isOpen} onClose={() => { setRealtimePoints(0); onClose(); }} > <ModalOverlay /> <ModalContent> <ModalHeader>Modify Points</ModalHeader> <ModalCloseButton /> <ModalBody> <NumberInput allowMouseWheel defaultValue={realtimePoints} onChange={(n) => setRealtimePoints(parseInt(n))} mt="8px" > <NumberInputField /> <NumberInputStepper> <NumberIncrementStepper /> <NumberDecrementStepper /> </NumberInputStepper> </NumberInput> </ModalBody> <ModalFooter> <Button onClick={() => { setRealtimePoints(0); onClose(); }} > Cancel </Button> <Button colorScheme="blue" ml="8px" onClick={async () => await updatePoints(currentModalMember, realtimePoints) } > Update Points </Button> </ModalFooter> </ModalContent> </Modal> <Modal isOpen={isDelConfirmOpen} onClose={closeDelConfirm}> <ModalOverlay /> <ModalContent> <ModalHeader>Remove Member</ModalHeader> <ModalCloseButton /> <ModalBody> <Text> You are about to remove {currentDelMember.name} from the Events Team roster, this will clear all of their data. Are you sure you want to do this? </Text> </ModalBody> <ModalFooter> <Button colorScheme="blue" onClick={() => { setDelMember({ id: "", name: "" }); closeDelConfirm(); }} > No </Button> <Button colorScheme="red" onClick={async () => { await removeMember(currentDelMember.id); setDelMember({ id: "", name: "" }); closeDelConfirm(); }} ml="8px" > Yes, Remove </Button> </ModalFooter> </ModalContent> </Modal> <Modal isOpen={isAddMemberOpen} onClose={closeAddMember}> <ModalOverlay /> <ModalContent> <ModalHeader>Add Member</ModalHeader> <ModalCloseButton /> <ModalBody> <Heading size="xs">User ID</Heading> <Input maxLength={19} onBeforeInput={(e) => { const { data, }: { data?: string } & FormEvent<HTMLInputElement> = e; if (data?.match(/\D/)) e.preventDefault(); }} onChange={(e) => setAddingMemberId(e.target.value)} mb="16px" type="number" /> <Heading size="xs">Name</Heading> <Input maxLength={64} onChange={(e) => setAddingMemberName(e.target.value)} mb="16px" /> <Heading size="xs">Roblox Username (optional)</Heading> <Input maxLength={20} onBeforeInput={validateRobloxName} onChange={(e) => setAddingMemberRoblox(e.target.value)} /> </ModalBody> <ModalFooter> <Button onClick={closeAddMember}>Close</Button> <Button colorScheme="blue" onClick={async () => await addMember()} ml="8px" > Add </Button> </ModalFooter> </ModalContent> </Modal> <Heading>Events Team Members</Heading> <TableContainer mt="16px"> <Table variant="simple"> <TableCaption> Click/tap on a user's points count to change their points, their user id to see and manage strikes. </TableCaption> <Thead> <Tr> <Th>Discord ID</Th> <Th>Name</Th> <Th>Roblox ID</Th> <Th>Points</Th> <Th>Remove</Th> </Tr> </Thead> <Tbody> {memberData.map((member) => ( <Tr> <Td> <Link href={`/et-members/strikes/${member.id}`}> {member.id} </Link> </Td> <Td> <Link onClick={() => { setModalMember(member.id); openNameChange(); }} > {member.name} </Link> </Td> <Td> <Link onClick={() => { setModalMember(member.id); openChangeRoblox(); }} > {member.roblox_id} </Link> </Td> <Td> <Link onClick={() => { setModalMember(member.id); onOpen(); }} > {member.points} </Link> </Td> <Td> <Link onClick={() => { setDelMember({ id: member.id, name: member.name }); openDelConfirm(); }} > Remove </Link> </Td> </Tr> ))} </Tbody> </Table> </TableContainer> <Link color="#646cff" onClick={openAddMember} mt="16px"> Add Member </Link> </Container> ); }