475 lines
12 KiB
TypeScript
475 lines
12 KiB
TypeScript
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 = {
|
|
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 } = {
|
|
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 << 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 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);
|
|
|
|
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(),
|
|
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>
|
|
);
|
|
}
|