import { Box, Button, Card, CardBody, CardHeader, Container, Flex, Heading, HStack, Image, Input, InputGroup, InputRightElement, Link, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Spacer, Stack, StackDivider, Text, useDisclosure, useToast, } from "@chakra-ui/react"; import { type FormEvent, type ReactElement, useState } from "react"; export async function loader({ context }: { context: RequestContext }) { const { current_user: currentUser } = context.data; if (!currentUser) throw new Response(null, { status: 401, }); if ( !(currentUser.permissions & (1 << 5)) && !(currentUser.permissions & (1 << 8)) ) throw new Response(null, { status: 403, }); return null; } export function meta() { return [{ title: "Hammer - Car Crushers" }]; } export default function () { const [queriedUsername, setQueriedUsername] = useState(""); const [username, setUsername] = useState(""); const [uid, setUid] = useState(""); const [status, setStatus] = useState(""); const [visible, setVisible] = useState(false); const [avatarUrl, setAvatarUrl] = useState(""); const [ticketLink, setTicketLink] = useState(""); const [history, setHistory] = useState([] as ReactElement[]); const [loading, setLoading] = useState(false); const { isOpen, onClose, onOpen } = useDisclosure(); const toast = useToast(); const ticketRegex = /https:\/\/carcrushers\.modmail\.dev\/logs\/[a-f\d]{12}$/; async function getHistory() { setVisible(false); setLoading(true); setHistory([]); if (queriedUsername.length < 4) { setLoading(false); return toast({ title: "Validation Error", description: `Username is too short`, status: "error", }); } const historyResp = await fetch( `/api/game-bans/${queriedUsername}/history`, ); if (!historyResp.ok) { setLoading(false); return toast({ title: "Failed To Fetch User", description: `${ ((await historyResp.json()) as { error: string }).error }`, status: "error", }); } const { history, user, }: { history: { [k: string]: any }[]; user: { avatar: string | null; current_status: string; id: number; name: string; }; } = await historyResp.json(); setAvatarUrl(user.avatar ?? "https://i.hep.gg/floppa"); setUid(user.id.toString()); setUsername(user.name); setStatus(user.current_status); const cardList = []; for (const entry of history) { const url = entry.entity.properties.evidence.stringValue; const isUrl = () => { try { new URL(url).href; return true; } catch { return false; } }; cardList.push( <Container mb={3}> <Card> <CardHeader> <Heading size="md"> {new Date( parseInt(entry.entity.properties.executed_at.integerValue), ).toLocaleString()} </Heading> </CardHeader> <CardBody> <Stack divider={<StackDivider />} spacing="4"> <Box> <Heading size="xs">ACTION</Heading> <Text pt="2" size="sm"> {entry.entity.properties.action.stringValue} </Text> </Box> <Box> <Heading size="xs">EVIDENCE</Heading> <Text pt="2" size="sm"> {isUrl() ? ( <Link color="#646cff" href={url}> {url} </Link> ) : ( url )} </Text> </Box> </Stack> </CardBody> </Card> </Container>, ); } setHistory(cardList); setLoading(false); setVisible(true); } const invalidIcon = ( <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" color="red" > <path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z" /> </svg> ); const validIcon = ( <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" color="green" > <path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z" /> </svg> ); async function revokePunishment() { const revokeResponse = await fetch(`/api/game-bans/${uid}/revoke`, { body: JSON.stringify({ ticket_link: ticketLink }), headers: { "content-type": "application/json", }, method: "POST", }); if (!revokeResponse.ok) { let error: string; try { error = ((await revokeResponse.json()) as { error: string }).error; } catch { error = "Unknown error"; } toast({ description: error, isClosable: true, status: "error", title: "Oops", }); } else { toast({ description: `Punishment revoked for ${username}`, isClosable: true, status: "success", title: "Success", }); } onClose(); setTicketLink(""); } return ( <Container maxW="container.md"> <Modal isCentered isOpen={isOpen} onClose={onClose} size="lg"> <ModalOverlay /> <ModalContent> <ModalHeader>Revoke punishment for {username}</ModalHeader> <ModalCloseButton /> <ModalBody> <InputGroup> <Input onChange={(e) => setTicketLink(e.target.value)} placeholder="https://carcrushers.modmail.dev/logs/abcdef123456" maxLength={49} /> <InputRightElement> {ticketLink.match(ticketRegex) ? validIcon : invalidIcon} </InputRightElement> </InputGroup> </ModalBody> <ModalFooter> <Button onClick={() => { onClose(); setTicketLink(""); }} > Cancel </Button> <Button colorScheme="red" disabled={!Boolean(ticketLink.match(ticketRegex))} ml="8px" onClick={async () => await revokePunishment()} > Revoke </Button> </ModalFooter> </ModalContent> </Modal> <Heading>User Lookup</Heading> <Text>Look up a user's punishment history here.</Text> <HStack mt={5}> <Input id="username" onBeforeInput={(e) => { const { data }: { data?: string } & FormEvent<HTMLInputElement> = e; if (data?.match(/\W/)) e.preventDefault(); }} onChange={(e) => setQueriedUsername(e.target.value)} placeholder="Roblox username" /> <Button ml="8px" onClick={async () => await getHistory()} isLoading={loading} > Search </Button> </HStack> <Container mb={3} mt={3}> <Card visibility={visible ? "visible" : "hidden"}> <CardBody> <Flex flexWrap="wrap" justifyContent="center"> <Image mb="16" src={avatarUrl} /> <Spacer /> <Button colorScheme="red" onClick={onOpen} visibility={ status === "Not Moderated" || !status ? "hidden" : "visible" } > Revoke Punishment </Button> </Flex> <Stack divider={<StackDivider />} mt="8px" spacing="6"> <Box> <Heading size="xs">USERNAME</Heading> <Text pt="2" fontSize="sm"> {username} </Text> </Box> <Box> <Heading size="xs">USER ID</Heading> <Text pt="2" fontSize="sm"> {uid} </Text> </Box> <Box> <Heading size="xs">MODERATION STATUS</Heading> <Text pt="2" fontSize="sm"> {status} </Text> </Box> </Stack> </CardBody> </Card> </Container> {history} </Container> ); }