669 lines
19 KiB
TypeScript
669 lines
19 KiB
TypeScript
import {
|
|
Box,
|
|
Button,
|
|
Card,
|
|
CardBody,
|
|
CardFooter,
|
|
Container,
|
|
Flex,
|
|
FormControl,
|
|
FormLabel,
|
|
Heading,
|
|
Link,
|
|
Modal,
|
|
ModalBody,
|
|
ModalCloseButton,
|
|
ModalContent,
|
|
ModalFooter,
|
|
ModalHeader,
|
|
ModalOverlay,
|
|
Stack,
|
|
StackDivider,
|
|
Switch,
|
|
Text,
|
|
useDisclosure,
|
|
useToast,
|
|
VStack,
|
|
} from "@chakra-ui/react";
|
|
import { useLoaderData } from "@remix-run/react";
|
|
import { useState } from "react";
|
|
import calendarStyles from "react-big-calendar/lib/css/react-big-calendar.css";
|
|
import { type LinksFunction } from "@remix-run/cloudflare";
|
|
|
|
export const links: LinksFunction = () => {
|
|
return [{ href: calendarStyles, rel: "stylesheet" }];
|
|
};
|
|
|
|
export async function loader({ context }: { context: RequestContext }) {
|
|
if (!context.data.current_user)
|
|
throw new Response(null, {
|
|
status: 401,
|
|
});
|
|
|
|
if (
|
|
![1 << 3, 1 << 4, 1 << 12].find(
|
|
(p) => context.data.current_user.permissions & p,
|
|
)
|
|
)
|
|
throw new Response(null, {
|
|
status: 403,
|
|
});
|
|
|
|
const now = new Date();
|
|
const monthEventList = await context.env.D1.prepare(
|
|
"SELECT answer, approved, created_by, day, details, id, month, pending, performed_at, reached_minimum_player_count, type, year FROM events WHERE month = ? AND year = ? ORDER BY day ASC;",
|
|
)
|
|
.bind(now.getUTCMonth() + 1, now.getUTCFullYear())
|
|
.all();
|
|
|
|
if (monthEventList.error)
|
|
throw new Response(null, {
|
|
status: 500,
|
|
});
|
|
|
|
const membersList = await context.env.D1.prepare(
|
|
"SELECT id, name FROM et_members WHERE id IN (SELECT created_by FROM events WHERE month = ? AND year = ?);",
|
|
)
|
|
.bind(now.getUTCMonth() + 1, now.getUTCFullYear())
|
|
.all();
|
|
|
|
if (membersList.error)
|
|
throw new Response(null, {
|
|
status: 500,
|
|
});
|
|
|
|
return {
|
|
can_approve: Boolean(
|
|
[1 << 4, 1 << 12].find((p) => context.data.current_user.permissions & p),
|
|
),
|
|
events: monthEventList.results,
|
|
members: membersList.results as { id: string; name: string }[],
|
|
user_id: context.data.current_user.id as string,
|
|
};
|
|
}
|
|
|
|
export default function () {
|
|
const {
|
|
can_approve,
|
|
events,
|
|
members,
|
|
user_id,
|
|
}: {
|
|
can_approve: boolean;
|
|
events: { [k: string]: any }[];
|
|
members: { id: string; name: string }[];
|
|
user_id: string;
|
|
} = useLoaderData<typeof loader>();
|
|
const [eventData, setEventData] = useState(events);
|
|
const { isOpen, onClose, onOpen } = useDisclosure();
|
|
const {
|
|
isOpen: isCompleteOpen,
|
|
onClose: closeComplete,
|
|
onOpen: openComplete,
|
|
} = useDisclosure();
|
|
const {
|
|
isOpen: isAnsweredOpen,
|
|
onClose: closeAnswered,
|
|
onOpen: openAnswered,
|
|
} = useDisclosure();
|
|
const {
|
|
isOpen: isForgottenOpen,
|
|
onClose: onForgottenClose,
|
|
onOpen: onForgottenOpen,
|
|
} = useDisclosure();
|
|
const {
|
|
isOpen: isDeleteOpen,
|
|
onClose: onDeleteClose,
|
|
onOpen: onDeleteOpen,
|
|
} = useDisclosure();
|
|
const toast = useToast();
|
|
const [selectedEvent, setSelectedEvent] = useState("");
|
|
const [showOld, setShowOld] = useState(false);
|
|
|
|
async function decide(approved: boolean, eventId: string) {
|
|
const decisionResp = await fetch(
|
|
`/api/events-team/events/${eventId}/decision`,
|
|
{
|
|
body: JSON.stringify({ approved }),
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
method: "POST",
|
|
},
|
|
);
|
|
|
|
if (!decisionResp.ok) {
|
|
let errorMsg = "Unknown error";
|
|
|
|
try {
|
|
errorMsg = ((await decisionResp.json()) as { error: string }).error;
|
|
} catch {}
|
|
|
|
toast({
|
|
description: errorMsg,
|
|
status: "error",
|
|
title: "Oops!",
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
toast({
|
|
description: `Event ${approved ? "approved" : "rejected"}`,
|
|
status: "success",
|
|
title: "Success",
|
|
});
|
|
|
|
const newEventData = eventData;
|
|
const eventIdx = eventData.findIndex((e) => e.id === eventId);
|
|
|
|
newEventData[eventIdx].approved = approved;
|
|
newEventData[eventIdx].pending = false;
|
|
|
|
setEventData([...newEventData]);
|
|
}
|
|
|
|
async function certify(eventId: string) {
|
|
const certifyResp = await fetch(
|
|
`/api/events-team/events/${eventId}/certify`,
|
|
{
|
|
body: "{}",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
method: "POST",
|
|
},
|
|
);
|
|
|
|
if (!certifyResp.ok) {
|
|
let errorMsg = "Unknown error";
|
|
|
|
try {
|
|
errorMsg = ((await certifyResp.json()) as { error: string }).error;
|
|
} catch {}
|
|
|
|
toast({
|
|
description: errorMsg,
|
|
status: "error",
|
|
title: "Failed to certify game night",
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
toast({
|
|
description: "Game night certified",
|
|
status: "success",
|
|
title: "Success",
|
|
});
|
|
|
|
const newEventData = eventData;
|
|
newEventData[
|
|
eventData.findIndex((e) => e.id === eventId)
|
|
].reached_minimum_player_count = true;
|
|
|
|
setEventData([...newEventData]);
|
|
setSelectedEvent("");
|
|
}
|
|
|
|
async function markAnswered(eventId: string) {
|
|
const answerResp = await fetch(`/api/events-team/events/${eventId}/solve`, {
|
|
body: "{}",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
method: "POST",
|
|
});
|
|
|
|
closeAnswered();
|
|
|
|
if (!answerResp.ok) {
|
|
toast({
|
|
description: "Failed to mark as solved",
|
|
status: "error",
|
|
title: "Oops",
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
const newEventData = eventData;
|
|
newEventData[eventData.findIndex((e) => e.id === eventId)].answered_at =
|
|
Date.now();
|
|
|
|
setEventData([...newEventData]);
|
|
setSelectedEvent("");
|
|
}
|
|
|
|
async function markComplete(eventId: string) {
|
|
const completeResp = await fetch(
|
|
`/api/events-team/events/${eventId}/complete`,
|
|
{
|
|
body: "{}",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
method: "POST",
|
|
},
|
|
);
|
|
|
|
closeComplete();
|
|
|
|
if (!completeResp.ok) {
|
|
let msg = "Unknown error";
|
|
|
|
try {
|
|
msg = ((await completeResp.json()) as { error: string }).error;
|
|
} catch {}
|
|
|
|
toast({
|
|
description: msg,
|
|
status: "error",
|
|
title: "Failed to complete",
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
toast({
|
|
description: "Event marked as completed",
|
|
status: "success",
|
|
title: "Success",
|
|
});
|
|
|
|
const newEventData = eventData;
|
|
|
|
// Technically this won't be the same as the time in the db, but that doesn't matter since this is just to hide the button
|
|
newEventData[eventData.findIndex((e) => e.id === eventId)].performed_at =
|
|
Date.now();
|
|
|
|
setEventData([...newEventData]);
|
|
setSelectedEvent("");
|
|
}
|
|
|
|
async function markForgotten(eventId: string) {
|
|
const forgottenResp = await fetch(
|
|
`/api/events-team/events/${eventId}/forgotten`,
|
|
{
|
|
body: "{}",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
method: "POST",
|
|
},
|
|
);
|
|
|
|
onForgottenClose();
|
|
|
|
if (!forgottenResp.ok) {
|
|
let msg = "Unknown error";
|
|
|
|
try {
|
|
msg = ((await forgottenResp.json()) as { error: string }).error;
|
|
} catch {}
|
|
|
|
toast({
|
|
description: msg,
|
|
status: "error",
|
|
title: "Failed to forget",
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
const newEventData = eventData;
|
|
|
|
newEventData[eventData.findIndex((e) => e.id === eventId)].performed_at = 0;
|
|
setEventData([...newEventData]);
|
|
setSelectedEvent("");
|
|
}
|
|
|
|
async function deleteEvent(eventId: string) {
|
|
const deleteResp = await fetch(`/api/events-team/events/${eventId}`, {
|
|
method: "DELETE",
|
|
});
|
|
|
|
onDeleteClose();
|
|
|
|
if (!deleteResp.ok) {
|
|
let msg = "Unknown error";
|
|
|
|
try {
|
|
msg = ((await deleteResp.json()) as { error: string }).error;
|
|
} catch {}
|
|
|
|
toast({
|
|
description: msg,
|
|
status: "error",
|
|
title: "Failed to delete",
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
setEventData(eventData.filter((e) => e.id !== eventId));
|
|
setSelectedEvent("");
|
|
}
|
|
|
|
return (
|
|
<Container maxW="container.lg">
|
|
<Modal isOpen={isOpen} onClose={onClose}>
|
|
<ModalOverlay />
|
|
<ModalContent>
|
|
<ModalHeader>Certify Game Night</ModalHeader>
|
|
<ModalCloseButton />
|
|
<ModalBody>
|
|
<Text>
|
|
By certifying this game night, you confirm that the minimum number
|
|
of players was met and you were provided proof.
|
|
</Text>
|
|
</ModalBody>
|
|
<ModalFooter>
|
|
<Button colorScheme="red" onClick={onClose}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
colorScheme="blue"
|
|
ml="8px"
|
|
onClick={async () => {
|
|
await certify(selectedEvent);
|
|
onClose();
|
|
}}
|
|
>
|
|
Certify
|
|
</Button>
|
|
</ModalFooter>
|
|
</ModalContent>
|
|
</Modal>
|
|
<Modal isOpen={isCompleteOpen} onClose={closeComplete}>
|
|
<ModalOverlay />
|
|
<ModalContent>
|
|
<ModalHeader>Mark as Completed</ModalHeader>
|
|
<ModalCloseButton />
|
|
<ModalBody>
|
|
<Text>
|
|
By marking this event as completed, you confirm that the event
|
|
creator has performed this event
|
|
</Text>
|
|
</ModalBody>
|
|
<ModalFooter>
|
|
<Button onClick={closeComplete}>Cancel</Button>
|
|
<Button
|
|
colorScheme="blue"
|
|
ml="8px"
|
|
onClick={async () => await markComplete(selectedEvent)}
|
|
>
|
|
Mark as Complete
|
|
</Button>
|
|
</ModalFooter>
|
|
</ModalContent>
|
|
</Modal>
|
|
<Modal isOpen={isAnsweredOpen} onClose={closeAnswered}>
|
|
<ModalOverlay />
|
|
<ModalContent>
|
|
<ModalHeader>Mark as Solved</ModalHeader>
|
|
<ModalCloseButton />
|
|
<ModalBody>
|
|
<Text>Are you sure you want to mark this riddle as solved?</Text>
|
|
</ModalBody>
|
|
<ModalFooter>
|
|
<Button onClick={closeAnswered}>Cancel</Button>
|
|
<Button
|
|
colorScheme="blue"
|
|
ml="8px"
|
|
onClick={async () => await markAnswered(selectedEvent)}
|
|
>
|
|
Mark Answered
|
|
</Button>
|
|
</ModalFooter>
|
|
</ModalContent>
|
|
</Modal>
|
|
<Modal isOpen={isForgottenOpen} onClose={onForgottenClose}>
|
|
<ModalOverlay />
|
|
<ModalContent>
|
|
<ModalHeader>Mark as Forgotten</ModalHeader>
|
|
<ModalCloseButton />
|
|
<ModalBody>
|
|
Are you sure you want to mark this event as forgotten? The creator
|
|
will be given a 5 point penalty.
|
|
</ModalBody>
|
|
<ModalFooter>
|
|
<Button onClick={onForgottenClose}>Cancel</Button>
|
|
<Button
|
|
colorScheme="blue"
|
|
ml="8px"
|
|
onClick={async () => await markForgotten(selectedEvent)}
|
|
>
|
|
Mark Forgotten
|
|
</Button>
|
|
</ModalFooter>
|
|
</ModalContent>
|
|
</Modal>
|
|
<Modal isOpen={isDeleteOpen} onClose={onDeleteClose}>
|
|
<ModalOverlay />
|
|
<ModalContent>
|
|
<ModalHeader>Delete Event?</ModalHeader>
|
|
<ModalCloseButton />
|
|
<ModalBody>
|
|
You are about to permanently delete this event. Are you sure you
|
|
want to continue?
|
|
</ModalBody>
|
|
<ModalFooter>
|
|
<Button onClick={onDeleteClose}>Cancel</Button>
|
|
<Button
|
|
colorScheme="red"
|
|
ml="8px"
|
|
onClick={async () => await deleteEvent(selectedEvent)}
|
|
/>
|
|
</ModalFooter>
|
|
</ModalContent>
|
|
</Modal>
|
|
<VStack spacing="8">
|
|
{eventData
|
|
.map((event) => {
|
|
if (!showOld && event.day < new Date().getUTCDate()) return;
|
|
|
|
const eventCreatorName = members.find(
|
|
(member) => member.id === event.created_by,
|
|
)?.name;
|
|
|
|
const eventColors: { [k: string]: string } = {
|
|
fotd: "cyan",
|
|
gamenight: "blue",
|
|
rotw: "magenta",
|
|
qotd: "#9900FF",
|
|
};
|
|
|
|
return (
|
|
<Card
|
|
borderColor={eventColors[event.type]}
|
|
borderWidth="2px"
|
|
w="100%"
|
|
>
|
|
<CardBody>
|
|
<Stack divider={<StackDivider />} spacing="4">
|
|
<Box>
|
|
<Heading size="sm">Date</Heading>
|
|
<Text fontSize="sm" pt="2">
|
|
{event.year}-{event.month}-{event.day}
|
|
</Text>
|
|
</Box>
|
|
<Box>
|
|
<Heading size="sm">Event Type</Heading>
|
|
<Text fontSize="sm" pt="2">
|
|
{event.type.toUpperCase()}
|
|
</Text>
|
|
</Box>
|
|
<Box>
|
|
<Heading size="sm">Event Details</Heading>
|
|
<Text fontSize="sm" pt="2">
|
|
{event.details}
|
|
</Text>
|
|
</Box>
|
|
{event.type === "rotw" ? (
|
|
<Box>
|
|
<Heading size="sm">Riddle Answer</Heading>
|
|
<Text fontSize="sm" pt="2">
|
|
{event.answer}
|
|
</Text>
|
|
</Box>
|
|
) : null}
|
|
<Box>
|
|
<Heading size="sm">Host</Heading>
|
|
<Text fontSize="sm" pt="2">
|
|
{eventCreatorName
|
|
? `${eventCreatorName} (${event.created_by})`
|
|
: event.created_by}
|
|
</Text>
|
|
</Box>
|
|
</Stack>
|
|
</CardBody>
|
|
<CardFooter>
|
|
<Flex gap="8px" mr="8px">
|
|
{can_approve && event.pending ? (
|
|
<>
|
|
<Button
|
|
colorScheme="red"
|
|
onClick={async () => await decide(false, event.id)}
|
|
>
|
|
Reject
|
|
</Button>
|
|
<Button
|
|
colorScheme="blue"
|
|
onClick={async () => await decide(true, event.id)}
|
|
>
|
|
Approve
|
|
</Button>
|
|
</>
|
|
) : null}
|
|
{can_approve &&
|
|
!event.pending &&
|
|
typeof event.performed_at !== "number" ? (
|
|
<>
|
|
<Button
|
|
colorScheme="blue"
|
|
onClick={() => {
|
|
setSelectedEvent(event.id);
|
|
openComplete();
|
|
}}
|
|
>
|
|
Mark as Completed
|
|
</Button>
|
|
<Button
|
|
colorScheme="red"
|
|
onClick={() => {
|
|
setSelectedEvent(event.id);
|
|
onForgottenOpen();
|
|
}}
|
|
>
|
|
Mark as Forgotten
|
|
</Button>
|
|
</>
|
|
) : null}
|
|
{can_approve &&
|
|
!event.pending &&
|
|
event.approved &&
|
|
event.performed_at &&
|
|
event.type === "rotw" &&
|
|
!event.answered_at ? (
|
|
<Button
|
|
colorScheme="blue"
|
|
onClick={() => {
|
|
setSelectedEvent(event.id);
|
|
openAnswered();
|
|
}}
|
|
>
|
|
Mark as Solved
|
|
</Button>
|
|
) : null}
|
|
{can_approve &&
|
|
event.approved &&
|
|
event.type === "gamenight" &&
|
|
event.performed_at &&
|
|
!event.reached_minimum_player_count ? (
|
|
<Button
|
|
colorScheme="blue"
|
|
onClick={() => {
|
|
setSelectedEvent(event.id);
|
|
onOpen();
|
|
}}
|
|
>
|
|
Certify Game Night
|
|
</Button>
|
|
) : null}
|
|
</Flex>
|
|
<Text alignSelf="center" fontSize="sm">
|
|
Status:{" "}
|
|
{event.pending
|
|
? "Pending"
|
|
: event.approved
|
|
? event.performed_at
|
|
? "Completed"
|
|
: "Approved"
|
|
: "Denied"}
|
|
</Text>
|
|
<Button
|
|
alignSelf="center"
|
|
display={
|
|
event.created_by === user_id || can_approve
|
|
? undefined
|
|
: "none"
|
|
}
|
|
marginLeft="auto"
|
|
marginRight={0}
|
|
onClick={() => {
|
|
setSelectedEvent(event.id);
|
|
onDeleteOpen();
|
|
}}
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="32"
|
|
height="32"
|
|
fill="currentColor"
|
|
viewBox="0 0 16 16"
|
|
>
|
|
<path d="M2.5 1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1H3v9a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V4h.5a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H10a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1zm3 4a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-1 0v-7a.5.5 0 0 1 .5-.5M8 5a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-1 0v-7A.5.5 0 0 1 8 5m3 .5v7a.5.5 0 0 1-1 0v-7a.5.5 0 0 1 1 0" />
|
|
</svg>
|
|
</Button>
|
|
</CardFooter>
|
|
</Card>
|
|
);
|
|
})
|
|
.filter((e) => e)}
|
|
</VStack>
|
|
<VStack alignItems="start" gap="8px" my="16px">
|
|
<FormControl>
|
|
<FormLabel htmlFor="show-old-events" mb="0">
|
|
Show old events
|
|
</FormLabel>
|
|
<Switch
|
|
id="show-old-events"
|
|
onChange={(e) => {
|
|
setShowOld(e.target.checked);
|
|
setEventData([...eventData]);
|
|
}}
|
|
/>
|
|
</FormControl>
|
|
<Link color="#646cff" href="/book-event" mt="16px" target="_blank">
|
|
Book an Event
|
|
</Link>
|
|
<Link
|
|
color="#646cff"
|
|
href="/events-team/historical"
|
|
mt="8px"
|
|
target="_blank"
|
|
>
|
|
Historical Events
|
|
</Link>
|
|
{can_approve ? (
|
|
<Link color="#646cff" href="/et-members" mb="32px" mt="8px">
|
|
Events Team Member Management
|
|
</Link>
|
|
) : null}
|
|
</VStack>
|
|
;
|
|
</Container>
|
|
);
|
|
}
|