393 lines
10 KiB
TypeScript

import {
Box,
Button,
Card,
CardBody,
CardFooter,
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 [saveData, setSaveData] = useState({} as { [k: string]: any });
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.evidence;
const isUrl = () => {
try {
new URL(url).href;
return true;
} catch {
return false;
}
};
cardList.push(
<Container mb={3}>
<Card>
<CardHeader>
<Heading size="md">
{new Date(entry.executed_at).toLocaleString()}
</Heading>
</CardHeader>
<CardBody>
<Stack divider={<StackDivider />} spacing="4">
<Box>
<Heading size="xs">ACTION</Heading>
<Text pt="2" size="sm">
{entry.action}
</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);
}
async function getSaveData() {
const dataResp = await fetch(`/api/game-bans/${uid}/save-data`);
if (!dataResp.ok) {
let msg = "Unknown error";
try {
msg = ((await dataResp.json()) as { error: string }).error;
} catch {}
toast({
description: msg,
status: "error",
title: "Failed to get save data",
});
return;
}
setSaveData(await dataResp.json());
}
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 my={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>
<CardFooter alignItems="center">
<Link onClick={async () => await getSaveData()}>
Click to Display Save Data
</Link>
</CardFooter>
</Card>
</Container>
{history}
<Container
display={Object.values(saveData).length ? undefined : "none"}
my="16px"
>
<Card>
<CardBody>
<Stack divider={<StackDivider />} mt="8px" spacing="6">
<Box>
<Heading size="xs">Money</Heading>
<Text pt="2" fontSize="sm">
{saveData.Money}
</Text>
</Box>
<Box>
<Heading size="xs">Parts</Heading>
<Text pt="2" fontSize="sm">
{saveData.Parts}
</Text>
</Box>
<Box>
<Heading size="xs">Wins</Heading>
<Text pt="2" fontSize="sm">
{saveData.Wins}
</Text>
</Box>
</Stack>
</CardBody>
</Card>
</Container>
</Container>
);
}