import { Box, Button, Container, Flex, Heading, HStack, 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 GameModManagementModal from "../../components/GameModManagementModal.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 = { active_inactivities: [1 << 0, 1 << 2, 1 << 3, 1 << 9, 1 << 10], appeal_bans: [1 << 0, 1 << 11], game_ban: [1 << 5], inactivity: [1 << 2, 1 << 3, 1 << 9, 1 << 10], infraction: [1 << 0, 1 << 2, 1 << 6, 1 << 7], user_lookup: [1 << 5, 1 << 8], }; const newItemNames: { [k: string]: string } = { active_inactivities: "Active Inactivity Notices", appeal_bans: "Appeal Bans", game_ban: "New Game Ban", gme: "Game Mod Management", inactivity: "New Inactivity Notice", infraction: "New Infraction", user_lookup: "User Lookup", }; const typePermissions = { appeal: [1 << 0, 1 << 11], 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 can_edit_ban_users = [ "165594923586945025", "289372404541554689", "396347223736057866", ].includes(currentUser.id); 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 (can_edit_ban_users) allowedNewItems.push({ name: "Game Mod Management", value: "gme" }); if (!allowedTypes.length && !allowedNewItems.length) throw new Response(null, { status: 403, }); return { can_edit_ban_users, 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); let queueReq: Response; try { queueReq = await fetch( `/api/mod-queue/${queueType}/list?before=${before}&showClosed=${show_closed}`, ); } catch { alert("Failed to load mod queue"); return; } 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; }; } = { active_inactivities: { isOpen: false, onClose: () => {}, onOpen: () => location.assign("/inactivities"), }, appeal_bans: useDisclosure(), game_ban: useDisclosure(), gme: useDisclosure(), inactivity: useDisclosure(), infraction: useDisclosure(), user_lookup: { isOpen: false, onClose: () => {}, onOpen: () => location.assign("/hammer"), }, }; useEffect(() => { messageChannel.current = new MessageChannel(); (async function () { if (!pageProps.entry_types.length) return; 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> ); const ToolsContent = ( <Popover placement="bottom-end"> <PopoverTrigger> <Button> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" > <path fillRule="evenodd" d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z" /> </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> ); return ( <Container maxW="container.lg"> <AppealBans isOpen={itemModals.appeal_bans.isOpen} onClose={itemModals.appeal_bans.onClose} /> <GameModManagementModal isOpen={itemModals.gme.isOpen} onClose={itemModals.gme.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%"> <HStack> {ItemDisplay} {ToolsContent} </HStack> </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"> <HStack> {ItemDisplay} {ToolsContent} </HStack> </Box> </Flex> </Container> ); }