2023-10-30 19:23:20 -04:00

456 lines
12 KiB
TypeScript

import {
Box,
Button,
Container,
Flex,
Heading,
Popover,
PopoverArrow,
PopoverBody,
PopoverCloseButton,
PopoverContent,
PopoverHeader,
PopoverTrigger,
Select,
Spacer,
useBreakpointValue,
useDisclosure,
useToast,
VStack,
} from "@chakra-ui/react";
import {
type MutableRefObject,
type ReactNode,
useEffect,
useRef,
useState,
} from "react";
import { useLoaderData } from "@remix-run/react";
import AppealBans from "../../components/AppealBans.js";
import AppealCard from "../../components/AppealCard.js";
import GameAppealCard from "../../components/GameAppealCard.js";
import NewGameBan from "../../components/NewGameBan.js";
import NewInfractionModal from "../../components/NewInfractionModal.js";
import ReportCard from "../../components/ReportCard.js";
import NewInactivityNotice from "../../components/NewInactivityNotice.js";
import InactivityNoticeCard from "../../components/InactivityNoticeCard.js";
export async function loader({ context }: { context: RequestContext }) {
const { current_user: currentUser } = context.data;
if (!currentUser)
throw new Response(null, {
status: 401,
});
const departments = {
DM: 1 << 2,
ET: 1 << 3,
FM: 1 << 10,
WM: 1 << 9,
};
const newItemPermissions = {
appeal_bans: [1 << 0, 1 << 11],
game_ban: [1 << 5],
inactivity: [1 << 2, 1 << 9, 1 << 10],
infraction: [1 << 0, 1 << 2, 1 << 6, 1 << 7],
user_lookup: [1 << 5, 1 << 8],
};
const newItemNames: { [k: string]: string } = {
appeal_bans: "Appeal Bans",
game_ban: "New Game Ban",
inactivity: "New Inactivity Notice",
infraction: "New Infraction",
user_lookup: "User Lookup",
};
const typePermissions = {
appeal: [1 << 0, 1 << 1],
gma: [1 << 5],
inactivity: [1 << 4, 1 << 6, 1 << 7, 1 << 11, 1 << 12],
report: [1 << 5],
};
const typeNames: { [k: string]: string } = {
appeal: "Discord Appeals",
gma: "Game Appeals",
inactivity: "Inactivity Notices",
report: "Game Reports",
};
const allowedNewItems = [];
const allowedTypes = [];
for (const [item, ints] of Object.entries(newItemPermissions)) {
if (ints.find((i) => currentUser.permissions & i))
allowedNewItems.push({ name: newItemNames[item], value: item });
}
for (const [type, ints] of Object.entries(typePermissions)) {
if (ints.find((i) => currentUser.permissions & i))
allowedTypes.push({ name: typeNames[type], value: type });
}
if (!allowedTypes.length && !allowedNewItems.length)
throw new Response(null, {
status: 403,
});
return {
can_edit_ban_users: [
"165594923586945025",
"289372404541554689",
"396347223736057866",
].includes(currentUser.id),
departments: Object.entries(departments)
.filter((d) => d[1] & currentUser.permissions)
.map((arr) => arr[0]),
entry_types: allowedTypes,
item_types: allowedNewItems,
};
}
export function meta() {
return [
{
title: "Moderation Queue - Car Crushers",
},
];
}
export default function () {
const pageProps = useLoaderData<typeof loader>();
const isDesktop = useBreakpointValue({ base: false, lg: true });
const entryTypes = [];
const [entries, setEntries] = useState(
[] as { element: ReactNode; id: string }[],
);
const [before, setBefore] = useState(Date.now());
const [queue, setQueue] = useState("");
const messageChannel: MutableRefObject<MessageChannel | null> = useRef(null);
const toast = useToast();
for (const type of pageProps.entry_types)
entryTypes.push(
<option key={type.value} value={type.value}>
{type.name}
</option>,
);
useEffect(() => {
if (messageChannel.current) {
messageChannel.current.port1.onmessage = function (ev) {
const { data }: { data: string } = ev;
setEntries([...entries].filter((entry) => entry.id !== data));
};
}
}, [entries, messageChannel.current]);
async function updateQueue(
queue_type: string,
before: number,
show_closed = false,
jump_item_to_top = false,
clear_all_others = false,
): Promise<void> {
const searchParams = new URLSearchParams(location.search);
const itemId = searchParams.get("id");
const queueType = searchParams.get("type") ?? queue_type;
if (!pageProps.entry_types.find((type) => type.value === queueType)) {
toast({
description: "You cannot access that queue",
isClosable: true,
status: "error",
title: "Forbidden",
});
return;
}
if (!searchParams.get("type") && itemId) {
toast({
description: "Cannot load item by id without type",
isClosable: true,
status: "error",
title: "Bad link",
});
return;
}
if (queueType !== queue_type) setQueue(queueType);
const queueReq = await fetch(
`/api/mod-queue/list?before=${before}&showClosed=${show_closed}&type=${queueType}`,
);
if (!queueReq.ok) {
const errorData: { error: string } = await queueReq.json();
toast({
description: errorData.error,
duration: 10000,
isClosable: true,
status: "error",
title: "Failed to load queue",
});
return;
}
let entryData: { [k: string]: any }[] = await queueReq.json();
const newEntries = clear_all_others ? [] : [...entries];
if (itemId && jump_item_to_top) {
history.replaceState(null, "", location.origin + location.pathname);
const specifiedItem = entryData.find((e) => e.id === itemId);
if (specifiedItem) {
entryData = entryData.filter((entry) => entry.id !== specifiedItem.id);
entryData.unshift(specifiedItem);
} else {
const itemReq = await fetch(`/api/mod-queue/${queueType}/${itemId}`);
if (!itemReq.ok) {
toast({
description: "Failed to load item with id " + itemId,
duration: 10000,
isClosable: true,
status: "error",
title: ((await itemReq.json()) as { error: string }).error,
});
} else {
const itemData: { [k: string]: any } = await itemReq.json();
entryData.unshift(itemData);
}
}
}
if (!entryData.length) {
setEntries([]);
return;
}
for (const entry of entryData) {
let cardType = queueType;
if (
entryData.indexOf(entry) > 0 &&
entryData.filter((d) => d.id === entry.id).length > 1
)
continue;
switch (cardType) {
case "appeal":
newEntries.push({
element: (
<AppealCard
{...(entry as AppealCardProps & { port?: MessagePort })}
port={messageChannel.current?.port2}
/>
),
id: `appeal_${entry.id}`,
});
break;
case "gma":
newEntries.push({
element: (
<GameAppealCard
{...(entry as GameAppealProps & { port?: MessagePort })}
port={messageChannel.current?.port2}
/>
),
id: `gma_${entry.id}`,
});
break;
case "inactivity":
newEntries.push({
element: (
<InactivityNoticeCard
{...(entry as InactivityNoticeProps & { port?: MessagePort })}
port={messageChannel.current?.port2}
/>
),
id: `inactivity_${entry.id}`,
});
break;
case "report":
newEntries.push({
element: (
<ReportCard
{...(entry as ReportCardProps & { port?: MessagePort })}
port={messageChannel.current?.port2}
/>
),
id: `report_${entry.id}`,
});
break;
}
}
setEntries(newEntries);
setBefore(entryData[entryData.length - 1].created_at);
}
const itemModals: {
[k: string]: {
isOpen: boolean;
onOpen: () => void;
onClose: () => void;
[k: string]: any;
};
} = {
appeal_bans: useDisclosure(),
game_ban: useDisclosure(),
inactivity: useDisclosure(),
infraction: useDisclosure(),
user_lookup: {
isOpen: false,
onClose: () => {},
onOpen: () => location.assign("/hammer"),
},
};
useEffect(() => {
messageChannel.current = new MessageChannel();
(async function () {
await updateQueue(pageProps.entry_types[0].value, before, false, true);
})();
const searchParams = new URLSearchParams(location.search);
const modal = searchParams.get("modal");
if (!modal || !pageProps.item_types.find((m) => m.value === modal)) return;
itemModals[modal].onOpen();
}, []);
const ItemDisplay = (
<Select
onChange={async (v) => {
setBefore(Date.now());
const { target } = v;
setQueue(target.options[target.selectedIndex].value);
await updateQueue(
target.options[target.selectedIndex].value,
Date.now(),
false,
false,
true,
);
}}
value={queue}
>
{entryTypes}
</Select>
);
return (
<Container maxW="container.lg">
<AppealBans
isOpen={itemModals.appeal_bans.isOpen}
onClose={itemModals.appeal_bans.onClose}
/>
<NewGameBan
isOpen={itemModals.game_ban.isOpen}
onClose={itemModals.game_ban.onClose}
/>
<NewInactivityNotice
departments={pageProps.departments}
isOpen={itemModals.inactivity.isOpen}
onClose={itemModals.inactivity.onClose}
/>
<NewInfractionModal
isOpen={itemModals.infraction.isOpen}
onClose={itemModals.infraction.onClose}
/>
<Flex>
<VStack w={isDesktop ? "container.md" : "container.lg"}>
<Box display={isDesktop ? "none" : undefined} mb="16px" w="90%">
{ItemDisplay}
</Box>
{entries.length ? (
entries.map((entry) => entry.element)
) : (
<Container
left="50%"
maxW="container.md"
pos="absolute"
mt="64px"
transform="translate(-50%)"
>
<Flex>
<Spacer />
<img alt="Thonkery" src="/files/Thonkery.png" />
<Spacer />
</Flex>
<br />
<Heading textAlign="center">Nothing here</Heading>
</Container>
)}
</VStack>
<Box display={isDesktop ? undefined : "none"} ml="16px" w="248px">
{ItemDisplay}
</Box>
</Flex>
<Popover placement="top-end">
<PopoverTrigger>
<Button
borderRadius="50%"
bottom="10vh"
h="16"
position="absolute"
right="10vh"
w="16"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
fill="currentColor"
viewBox="0 0 16 16"
>
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z" />
</svg>
</Button>
</PopoverTrigger>
<PopoverContent>
<PopoverArrow />
<PopoverCloseButton />
<PopoverHeader>Tools</PopoverHeader>
<PopoverBody>
<VStack>
{pageProps.item_types.map((item) => (
<Button
key={item.value}
onClick={() => itemModals[item.value].onOpen()}
w="100%"
>
{item.name}
</Button>
))}
</VStack>
</PopoverBody>
</PopoverContent>
</Popover>
</Container>
);
}