feat(app): added email send support
Build and Push Docker image / build-and-push (push) Has been cancelled Details

removed the in-app chat and added email send support in its place
This commit is contained in:
Valentin Kolb 2024-10-31 18:00:48 +01:00
parent 3895b86515
commit fc1103b61a
57 changed files with 1215 additions and 1410 deletions

View File

@ -5,8 +5,8 @@ import Layout from "@/components/layout";
import QRCodeGenerator from "./pages/util/qr/index.page.tsx"; import QRCodeGenerator from "./pages/util/qr/index.page.tsx";
import EventsRouter from "./pages/events/EventsRouter.tsx"; import EventsRouter from "./pages/events/EventsRouter.tsx";
import LegalPage from "@/pages/LegalPage.tsx"; import LegalPage from "@/pages/LegalPage.tsx";
import ChatRouter from "@/pages/chat/ChatRouter.tsx";
import DebugPage from "@/pages/test/DebugPage.tsx"; import DebugPage from "@/pages/test/DebugPage.tsx";
import EmailRouter from "@/pages/email/EmailRouter.tsx";
const router = createBrowserRouter([ const router = createBrowserRouter([
{ {
@ -26,8 +26,8 @@ const router = createBrowserRouter([
element: <EventsRouter/>, element: <EventsRouter/>,
}, },
{ {
path: "chat/*", path: "email/*",
element: <ChatRouter/>, element: <EmailRouter/>,
}, },
{ {
path: "debug", path: "debug",

View File

@ -0,0 +1,25 @@
.header {
/*noinspection CssInvalidFunction*/
background-color: light-dark(var(--mantine-color-blue-1), var(--mantine-color-dark-9));
padding: var(--padding);
width: 100%;
max-width: 100%;
display: flex;
justify-content: space-between;
}
.container {
overflow: hidden;
display: flex;
flex-direction: column;
}
.inner {
padding: var(--padding);
height: 100%;
flex-grow: 1;
}
.editorContainer {
flex-grow: 1;
height: 50vh;
}

View File

@ -0,0 +1,244 @@
import {ActionIcon, Alert, Button, Group, Modal, ModalProps, TextInput, Tooltip} from "@mantine/core";
import {
IconArrowsMaximize,
IconArrowsMinimize,
IconInfoSquareRounded,
IconMailCheck,
IconMailQuestion,
IconMailStar,
IconMailUp,
IconX
} from "@tabler/icons-react";
import UserInput from "@/components/users/UserInput.tsx";
import {hasLength, isNotEmpty, useForm} from "@mantine/form";
import {UserModel} from "@/models/AuthTypes.ts";
import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx";
import TextEditor from "@/components/input/Editor";
import {useMutation} from "@tanstack/react-query"
import classes from "./index.module.css"
import {useDisclosure} from "@mantine/hooks";
import ShowHelp from "@/components/ShowHelp.tsx";
import {useEffect, useState} from "react";
import {showSuccessNotification} from "@/components/util.tsx";
/**
* This component provides an email modal that can be used to send emails.
* It returns a function to toggle the modal and the modal component itself.
* @param onSuccess - the function to call when the email was send (optional)
* @param onCancel - the function to call when the user cancels (optional)
* @param recipients - set the recipients of the email (optional)
* @param subject - set the subject of the email (optional)
* @param content - set the content of the email (optional)
* @param disableUserInput - disable the user input (optional)
* @param props - additional props for the modal
*/
export const EmailModal = ({onSuccess, onCancel, recipients, subject, content, disableUserInput, ...props}: {
description?: string,
onSuccess?: () => void,
onCancel?: () => void,
recipients?: UserModel[],
subject?: string,
content?: string,
disableUserInput?: boolean
} & ModalProps) => {
const {user, pb} = usePB()
const [fullscreen, fullscreenHandler] = useDisclosure(false)
const formValues = useForm({
initialValues: {
recipients: recipients ?? [] as UserModel[],
subject: "",
content: ""
},
validate: {
recipients: hasLength({min: 1}, "Bitte wähle mindestens eine Empfängerin aus."),
subject: isNotEmpty("Bitte gib einen Betreff ein."),
content: isNotEmpty("Bitte gib einen Inhalt ein.")
}
})
useEffect(() => {
formValues.setFieldValue("recipients", recipients ?? [])
}, [recipients])
useEffect(() => {
formValues.setFieldValue("subject", subject ?? "")
}, [subject])
useEffect(() => {
formValues.setFieldValue("content", content ?? "")
}, [content])
const [testEmailWasSent, setTestEmailWasSent] = useState(false)
const sendTestEmailMutation = useMutation({
mutationFn: async () => {
await pb.collection("emails").create({
sender: user?.id,
recipients: user?.id,
subject: formValues.values.subject.trim() + " (Test Email)",
content: formValues.values.content
})
},
onSuccess: () => {
setTestEmailWasSent(true)
setTimeout(() => {
setTestEmailWasSent(false)
}, 1000)
showSuccessNotification("Test Email wurde gesendet.")
}
})
const sendEmailMutation = useMutation({
mutationFn: async () => {
await pb.collection("emails").create({
sender: user?.id,
recipients: formValues.values.recipients.map(r => r.id),
subject: formValues.values.subject.trim(),
content: formValues.values.content
})
},
onSuccess: () => {
formValues.reset()
props.onClose()
onSuccess?.()
showSuccessNotification(
"Du kannst deine gesendeten Emails auf der Email Seite einsehen."
)
}
})
if (!user) return <>
<Modal
size={"sm"}
{...props}
title={"Bitte logge dich ein"}
>
<Alert variant="light" color="blue" icon={<IconMailQuestion/>}>
Bitte logge dich ein, um eine Email zu senden.
</Alert>
</Modal>
</>
return <>
<Modal
size={"xl"}
{...props}
styles={{
body: {
padding: 0,
},
content: {
marginTop: "auto",
borderBottomRightRadius: 0,
borderBottomLeftRadius: 0,
}
}}
yOffset={0}
transitionProps={{transition: 'slide-up'}}
fullScreen={fullscreen}
withCloseButton={false}
>
<div>
<div className={classes.header}>
<Button
loading={sendEmailMutation.isPending}
leftSection={<IconMailUp/>}
color={"blue"}
variant={"subtle"}
onClick={() => {
if (formValues.validate().hasErrors) return
sendEmailMutation.mutate()
}}
>
Email Senden
</Button>
<Group>
<Tooltip label={"Test Email an dich senden"}>
<ActionIcon
onClick={() => {
if (formValues.validate().hasErrors) return
sendTestEmailMutation.mutate()
}}
color={"gray"}
variant={"subtle"}
title={"Send test email"}
>
{
testEmailWasSent ?
<IconMailCheck color={"green"}/> :
<IconMailStar/>
}
</ActionIcon>
</Tooltip>
<ActionIcon
onClick={() => {
fullscreenHandler.toggle()
}}
color={"gray"}
variant={"subtle"}
title={"Fullscreen"}
>
{fullscreen ? <IconArrowsMinimize/> : <IconArrowsMaximize/>}
</ActionIcon>
<ActionIcon
onClick={() => {
props.onClose()
onCancel?.()
}}
color={"gray"}
variant={"subtle"}
title={"Close"}
>
<IconX/>
</ActionIcon>
</Group>
</div>
<div className={`${classes.inner} stack`}>
<PocketBaseErrorAlert error={sendEmailMutation.error}/>
<PocketBaseErrorAlert error={sendTestEmailMutation.error}/>
<ShowHelp>
Die in deinem Account gespeicherte E-Mail-Adresse wird in der gesendeten Nachricht
angezeigt, damit die Empfängerin weiß, von wem die Nachricht stammt.
</ShowHelp>
<UserInput
required
disabled={disableUserInput}
placeholder={"Empfängerinnen"}
variant={"filled"}
selectedRecords={formValues.values.recipients}
setSelectedRecords={(val) => formValues.setFieldValue("recipients", val)}
error={formValues.errors.recipients}
/>
<TextInput
leftSection={<IconInfoSquareRounded/>}
variant={"filled"}
placeholder={"Betreff"}
required
{...formValues.getInputProps("subject")}
/>
<TextEditor
noBorder
minHeight={"40vh"}
maxHeight={"50vh"}
placeholder={"Inhalt der Email"}
value={formValues.values.content}
onChange={(value) => formValues.setFieldValue("content", value)}
error={formValues.errors.content}
/>
</div>
</div>
</Modal>
</>
}

View File

@ -0,0 +1,71 @@
import {ListResult} from "pocketbase";
import {InfiniteData, UseInfiniteQueryResult} from "@tanstack/react-query";
import {Modal, Progress, Text} from "@mantine/core";
import {useEffect} from "react";
/**
* A modal component that handles the infinite query loading process.
* The modal will be opened when the start flag is set to true while the query is fetching data.
* When the data fetching is completed, the onSuccess callback will be called.
*
* During fetching, the modal will show the progress of the fetching process.
*
* @param {Object} props - The properties object.
* @param {boolean} props.start - A flag to start the loading process.
* @param {Function} [props.onSuccess] - A callback function to be called when all pages are fetched successfully.
* @param {Function} [props.onError] - A callback function to be called when an error occurs during fetching.
* @param {UseInfiniteQueryResult<InfiniteData<ListResult, unknown>, Error>} props.query - The infinite query result object.
*
* @returns The modal component.
*/
export default function LoadInfinitQueryModal<T>({start, query, onSuccess, onError}: {
start: boolean
onSuccess?: () => void
onError?: (error: Error) => void
query: UseInfiniteQueryResult<InfiniteData<ListResult<T>, unknown>, Error>
}) {
// Fetch all pages
useEffect(() => {
// Fetch next page if not fetching and hasNextPage
if (start && query.hasNextPage && !query.isFetchingNextPage) {
query.fetchNextPage().catch(onError)
}
// Call onSuccess when all pages are fetched
if (start && query.isFetched && !query.hasNextPage) {
onSuccess?.()
}
}, [start, query, onSuccess])
const totalItems = query.data?.pages?.[0].totalItems || 0
const fetchedItems = query.data?.pages?.reduce((acc, page) => acc + page.items.length, 0) || 0
const totalPages = query.data?.pages?.[0].totalPages || 0
const fetchedPages = query.data?.pages?.length || 0
const progress = (fetchedPages / totalPages * 100) || 0
const dataIsFetching = query.isFetching || query.isPending
return <Modal
opened={start && progress < 100}
onClose={() => null}
size="xs"
centered
withCloseButton={false}
>
<div className={"stack"}>
{dataIsFetching && <>
<Text c={"dimmed"} size={"xs"}>
Daten werden gesammelt ... {fetchedItems}/{totalItems} Einträge
</Text>
<Progress.Root size="xl">
<Progress.Section value={progress} animated>
<Progress.Label>~{progress.toFixed(0)}%</Progress.Label>
</Progress.Section>
</Progress.Root>
</>}
</div>
</Modal>
}

View File

@ -99,14 +99,17 @@ export default function FormFilter({schema, label, defaultValue, onChange}: {
} }
}) })
formValues.setValues(newValues) formValues.setValues(newValues)
console.log("Schema changed", values, newValues)
// eslint-disable-next-line // eslint-disable-next-line
}, [schema]) }, [schema])
return <> return <>
<Popover width={300} position="bottom" withArrow shadow="md"> <Popover width={300} position="bottom" withArrow shadow="md">
<Popover.Target> <Popover.Target>
<Button size={"xs"} leftSection={<IconFilterPlus size={16}/>}> <Button
disabled={schema.fields.length === 0}
size={"xs"}
leftSection={<IconFilterPlus size={16}/>}
>
{label || "Nach Werten filter"} {label || "Nach Werten filter"}
</Button> </Button>
</Popover.Target> </Popover.Target>

View File

@ -63,6 +63,8 @@ const Toolbar = ({fullToolbar}: { fullToolbar: boolean, editor: Editor }) => (
<RichTextEditor.Code/> <RichTextEditor.Code/>
</RichTextEditor.ControlsGroup> </RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup> <RichTextEditor.ControlsGroup>
<RichTextEditor.BulletList/>
<RichTextEditor.OrderedList/>
<RichTextEditor.Link/> <RichTextEditor.Link/>
<RichTextEditor.Unlink/> <RichTextEditor.Unlink/>
</RichTextEditor.ControlsGroup> </RichTextEditor.ControlsGroup>
@ -79,6 +81,9 @@ const Toolbar = ({fullToolbar}: { fullToolbar: boolean, editor: Editor }) => (
* @param placeholder The placeholder text to show when the editor is empty. * @param placeholder The placeholder text to show when the editor is empty.
* @param fullToolbar Whether to show the full toolbar or not. * @param fullToolbar Whether to show the full toolbar or not.
* @param maxHeight The maximum height of the editor. * @param maxHeight The maximum height of the editor.
* @param minHeight The minimum height of the editor.
* @param disabled Whether the editor is disabled or not.
* @param modEnter The callback to call when the user presses Mod+Enter.
* @param hideToolbar Whether to hide the toolbar or not. If hidden a bubble menu will be shown instead. * @param hideToolbar Whether to hide the toolbar or not. If hidden a bubble menu will be shown instead.
* @param noBorder shows no border if true * @param noBorder shows no border if true
* @param props The props to pass to the Mantine Input Wrapper component. * @param props The props to pass to the Mantine Input Wrapper component.
@ -89,6 +94,7 @@ export default function TextEditor({
placeholder, placeholder,
fullToolbar, fullToolbar,
maxHeight, maxHeight,
minHeight,
hideToolbar, hideToolbar,
noBorder, noBorder,
disabled, disabled,
@ -100,6 +106,7 @@ export default function TextEditor({
placeholder?: string; placeholder?: string;
fullToolbar?: boolean; fullToolbar?: boolean;
maxHeight?: number | string; maxHeight?: number | string;
minHeight?: number | string;
hideToolbar?: boolean; hideToolbar?: boolean;
noBorder?: boolean; noBorder?: boolean;
disabled?: boolean; disabled?: boolean;
@ -161,7 +168,7 @@ export default function TextEditor({
toolbar: classes.toolbar, toolbar: classes.toolbar,
}} }}
> >
<RichTextEditorContent mah={maxHeight ?? "100px"}/> <RichTextEditorContent mih={minHeight} mah={maxHeight ?? "100px"}/>
{hideToolbar ? <Bubble editor={editor}/> : <Toolbar editor={editor} fullToolbar={!!fullToolbar}/>} {hideToolbar ? <Bubble editor={editor}/> : <Toolbar editor={editor} fullToolbar={!!fullToolbar}/>}
</Box> </Box>
</Input.Wrapper> </Input.Wrapper>

View File

@ -1,6 +1,17 @@
import {RecordModel} from "pocketbase"; import {RecordModel} from "pocketbase";
import {UseMutationResult} from "@tanstack/react-query"; import {UseMutationResult} from "@tanstack/react-query";
import {CheckIcon, Combobox, Group, Pill, PillsInput, PillsInputProps, Stack, Text, useCombobox} from "@mantine/core"; import {
CheckIcon,
Combobox,
Group,
Pill,
PillsInput,
PillsInputProps,
ScrollArea,
Stack,
Text,
useCombobox
} from "@mantine/core";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
/* /*
@ -17,7 +28,7 @@ export type GenericRecordSearchInputProps<T> = {
selectedRecords: T[] selectedRecords: T[]
setSelectedRecords: (records: T[]) => void setSelectedRecords: (records: T[]) => void
placeholder?: string placeholder?: string
} & Pick<PillsInputProps, "label" | "description" | "leftSection" | "required" | "error"> } & Pick<PillsInputProps, "label" | "description" | "leftSection" | "required" | "error" | "variant" | "disabled">
/** /**
* RecordSearchInput is a generic component that can be used to create a searchable input field for selecting records from a database. * RecordSearchInput is a generic component that can be used to create a searchable input field for selecting records from a database.
@ -93,40 +104,46 @@ export default function RecordSearchInput<T extends RecordModel>(props: {
leftSection={props.leftSection} leftSection={props.leftSection}
required={props.required} required={props.required}
error={props.error} error={props.error}
variant={props.variant}
disabled={props.disabled}
> >
<Pill.Group> <ScrollArea.Autosize mah={100} scrollbarSize={8}>
{ <Pill.Group>
props.selectedRecords.map((selectedRecord) => ( {
<Pill props.selectedRecords.map((selectedRecord, index) => (
key={selectedRecord.id} <Pill
withRemoveButton key={`${selectedRecord.id}-${index}`}
onRemove={() => handleValueRemove(selectedRecord.id)} disabled={props.disabled}
> withRemoveButton
{props.recordToString(selectedRecord).displayName} onRemove={() => handleValueRemove(selectedRecord.id)}
</Pill> >
)) {props.recordToString(selectedRecord).displayName}
} </Pill>
))
}
<Combobox.EventsTarget> <Combobox.EventsTarget>
<PillsInput.Field <PillsInput.Field
onFocus={() => combobox.openDropdown()} disabled={props.disabled}
onBlur={() => combobox.closeDropdown()} onFocus={() => combobox.openDropdown()}
value={search} onBlur={() => combobox.closeDropdown()}
placeholder={props.placeholder} value={search}
onChange={(event) => { placeholder={props.placeholder}
combobox.updateSelectedOptionIndex() onChange={(event) => {
setSearch(event.currentTarget.value) combobox.updateSelectedOptionIndex()
props.recordSearchMutation.mutate(event.currentTarget.value) setSearch(event.currentTarget.value)
}} props.recordSearchMutation.mutate(event.currentTarget.value)
onKeyDown={(event) => { }}
if (event.key === 'Backspace' && search.length === 0) { onKeyDown={(event) => {
event.preventDefault(); if (event.key === 'Backspace' && search.length === 0) {
handleValueRemove(props.selectedRecords[props.selectedRecords.length - 1].id) event.preventDefault();
} handleValueRemove(props.selectedRecords[props.selectedRecords.length - 1].id)
}} }
/> }}
</Combobox.EventsTarget> />
</Pill.Group> </Combobox.EventsTarget>
</Pill.Group>
</ScrollArea.Autosize>
</PillsInput> </PillsInput>
</Combobox.DropdownTarget> </Combobox.DropdownTarget>

View File

@ -6,10 +6,9 @@ import {
IconConfetti, IconConfetti,
IconHome, IconHome,
IconList, IconList,
IconMessageCircle, IconMailShare,
IconQrcode, IconQrcode,
IconSectionSign, IconSectionSign
IconSpeakerphone
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import {useShowDebug} from "@/components/ShowDebug.tsx"; import {useShowDebug} from "@/components/ShowDebug.tsx";
@ -25,16 +24,10 @@ const NavItems = [
link: "/" link: "/"
}, },
{ {
title: "Nachrichten", title: "Emails",
icon: IconMessageCircle, icon: IconMailShare,
description: "Nachrichten", description: "Emails",
link: "/chat" link: "/email"
},
{
title: "Ankündigungen",
icon: IconSpeakerphone,
description: "Ankündigungen",
link: "/chat/announcements"
} }
] ]
}, },

View File

@ -4,7 +4,6 @@ import {ActionIcon, Image, Menu, ThemeIcon} from "@mantine/core";
import {IconChevronDown, IconLogin, IconUserStar} from "@tabler/icons-react"; import {IconChevronDown, IconLogin, IconUserStar} from "@tabler/icons-react";
import MenuItems from "./MenuItems.tsx"; import MenuItems from "./MenuItems.tsx";
import {useLogin, useUserMenu} from "@/components/users/modals/hooks.ts"; import {useLogin, useUserMenu} from "@/components/users/modals/hooks.ts";
import ChatNavIcon from "@/pages/chat/components/ChatNavIcon.tsx";
export default function NavBar() { export default function NavBar() {
@ -47,7 +46,6 @@ export default function NavBar() {
<div className={classes.actionIcons}> <div className={classes.actionIcons}>
{user ? {user ?
<> <>
<ChatNavIcon/>
<ActionIcon <ActionIcon
variant={"transparent"} variant={"transparent"}
color={"gray"} color={"gray"}

View File

@ -1,17 +1,17 @@
import {useMutation} from "@tanstack/react-query"; import {useMutation} from "@tanstack/react-query";
import {IconUsers} from "@tabler/icons-react"; import {IconUsers} from "@tabler/icons-react";
import {usePB} from "@/lib/pocketbase.tsx"; import {usePB} from "@/lib/pocketbase.tsx";
import {UserModal} from "@/models/AuthTypes.ts"; import {UserModel} from "@/models/AuthTypes.ts";
import RecordSearchInput, {GenericRecordSearchInputProps} from "../input/RecordSearchInput.tsx"; import RecordSearchInput, {GenericRecordSearchInputProps} from "../input/RecordSearchInput.tsx";
export default function UserInput(props: GenericRecordSearchInputProps<UserModal>) { export default function UserInput(props: GenericRecordSearchInputProps<UserModel>) {
const {pb} = usePB() const {pb} = usePB()
return ( return (
<RecordSearchInput <RecordSearchInput
<UserModal> <UserModel>
recordToString={(user) => ({displayName: `${user.givenName} ${user.sn}`})} recordToString={(user) => ({displayName: `${user.givenName} ${user.sn}`})}
{...props} {...props}
placeholder={props.placeholder || "Suche nach Personen..."} placeholder={props.placeholder || "Suche nach Personen..."}

View File

@ -1,9 +1,10 @@
import {UserModal} from "@/models/AuthTypes.ts"; import {UserModel} from "@/models/AuthTypes.ts";
import {List} from "@mantine/core"; import {List} from "@mantine/core";
import {IconUser} from "@tabler/icons-react"; import {IconUser} from "@tabler/icons-react";
import {usePB} from "@/lib/pocketbase.tsx"; import {usePB} from "@/lib/pocketbase.tsx";
import {getUserName} from "@/components/users/modals/util.tsx";
export default function UsersDisplay({users}: { users: UserModal[] }) { export default function UsersDisplay({users}: { users: UserModel[] }) {
const {user} = usePB() const {user} = usePB()
@ -12,7 +13,7 @@ export default function UsersDisplay({users}: { users: UserModal[] }) {
{ {
users.map((u) => ( users.map((u) => (
<List.Item key={u.id} fw={500} c={user && user.id == u.id ? "green" : ""}> <List.Item key={u.id} fw={500} c={user && user.id == u.id ? "green" : ""}>
{u.username} {getUserName(u)}
</List.Item> </List.Item>
)) ))
} }

View File

@ -34,7 +34,6 @@ import {getUserName} from "@/components/users/modals/util.tsx";
import {isNotEmpty, useForm} from "@mantine/form"; import {isNotEmpty, useForm} from "@mantine/form";
import {useMutation} from "@tanstack/react-query"; import {useMutation} from "@tanstack/react-query";
import {showSuccessNotification} from "@/components/util.tsx"; import {showSuccessNotification} from "@/components/util.tsx";
import {CheckboxCard} from "@/components/input/CheckboxCard";
import {useEffect} from "react"; import {useEffect} from "react";
export default function UserMenuModal() { export default function UserMenuModal() {
@ -62,7 +61,6 @@ export default function UserMenuModal() {
const values = { const values = {
sn: user?.sn ?? "", sn: user?.sn ?? "",
givenName: user?.givenName ?? "", givenName: user?.givenName ?? "",
muteEmailNotifications: user?.muteEmailNotifications ?? false
} }
formValues.setInitialValues(values) formValues.setInitialValues(values)
formValues.setValues(values) formValues.setValues(values)
@ -197,15 +195,16 @@ export default function UserMenuModal() {
sondern zeigt zusätzliche Informationen an. sondern zeigt zusätzliche Informationen an.
</ShowDebug> </ShowDebug>
<Divider
label={"Account"}
/>
{userHasNoName && {userHasNoName &&
<Alert> Bitte vervollständige deinen Account, um alle Funktionen nutzen zu können. </Alert>} <Alert> Bitte vervollständige deinen Account, um alle Funktionen nutzen zu können. </Alert>}
{ {
user?.REALM === "GUEST" && <> user?.REALM === "GUEST" && <>
<Divider
label={"Account"}
/>
<PocketBaseErrorAlert error={mutation.error}/> <PocketBaseErrorAlert error={mutation.error}/>
<TextInput <TextInput
@ -226,14 +225,6 @@ export default function UserMenuModal() {
</> </>
} }
<CheckboxCard
label={"Email Benachrichtigungen stummschalten"}
description={"Du erhältst keine Benachrichtigungen per Email für neu Chatnachrichten. " +
"Für Ankündigungen und wichtige Informationen bekommst du weiterhin Nachrichten."
}
{...formValues.getInputProps("muteEmailNotifications", {type: 'checkbox'})}
/>
<Group justify={"center"}> <Group justify={"center"}>
<Tooltip label={"Account Einstellungen speichern"}> <Tooltip label={"Account Einstellungen speichern"}>
<ActionIcon <ActionIcon

View File

@ -1,4 +1,4 @@
import {UserModal} from "@/models/AuthTypes.ts"; import {UserModel} from "@/models/AuthTypes.ts";
import {Tooltip} from "@mantine/core"; import {Tooltip} from "@mantine/core";
/** /**
@ -7,7 +7,7 @@ import {Tooltip} from "@mantine/core";
* @param user * @param user
*/ */
// eslint-disable-next-line react-refresh/only-export-components // eslint-disable-next-line react-refresh/only-export-components
export const getUserName = (user?: UserModal | null) => { export const getUserName = (user?: UserModel | null) => {
if (!user) { if (!user) {
return null return null
} }
@ -24,7 +24,7 @@ export const getUserName = (user?: UserModal | null) => {
* @param user * @param user
* @constructor * @constructor
*/ */
export const RenderUserName = ({user}: { user?: UserModal | null }) => { export const RenderUserName = ({user}: { user?: UserModel | null }) => {
if (!user) { if (!user) {
return null return null

View File

@ -31,6 +31,16 @@ export const pprintDate = (date: string | Date | Dayjs): string => {
return `${d.format('dd')} ${d.format('DD.MM.YY')}` return `${d.format('dd')} ${d.format('DD.MM.YY')}`
} }
/**
* Pretty print a time. The date is formatted as "HH:MM".
* @param date - The date string to pretty print.
* @return {string} The pretty printed time.
*/
export const pprintTime = (date: string | Date | Dayjs): string => {
const d = dayjs(date)
return `${d.format('HH:mm')}`
}
/** /**
* Pretty print a date and time. The date is formatted as "HH:MM DAY DD.MM.YYYY". * Pretty print a date and time. The date is formatted as "HH:MM DAY DD.MM.YYYY".
* Uses Dayjs * Uses Dayjs

View File

@ -10,7 +10,7 @@ import ms from "ms";
import {useQuery} from "@tanstack/react-query"; import {useQuery} from "@tanstack/react-query";
import {TypedPocketBase} from "@/models"; import {TypedPocketBase} from "@/models";
import {PB_BASE_URL, PB_STORAGE_KEY, PB_USER_COLLECTION} from "../../config.ts"; import {PB_BASE_URL, PB_STORAGE_KEY, PB_USER_COLLECTION} from "../../config.ts";
import {UserModal} from "@/models/AuthTypes.ts"; import {UserModel} from "@/models/AuthTypes.ts";
import {Alert, List} from "@mantine/core"; import {Alert, List} from "@mantine/core";
import {IconAlertTriangle} from "@tabler/icons-react"; import {IconAlertTriangle} from "@tabler/icons-react";
import {showSuccessNotification} from "@/components/util.tsx"; import {showSuccessNotification} from "@/components/util.tsx";
@ -182,7 +182,7 @@ const PocketData = () => {
ldapLogin, ldapLogin,
guestLogin, guestLogin,
logout, logout,
user: pb.authStore.isValid ? user as UserModal | null : null, user: pb.authStore.isValid ? user as UserModel | null : null,
pb, pb,
refreshUser: refreshUserQuery.refetch, refreshUser: refreshUserQuery.refetch,
useSubscription, useSubscription,

View File

@ -1,11 +1,10 @@
import {RecordModel} from "pocketbase"; import {RecordModel} from "pocketbase";
export type UserModal = { export type UserModel = {
username: string; username: string;
verified: boolean; verified: boolean;
email: string; email: string;
emailVisibility: boolean; emailVisibility: boolean;
muteEmailNotifications: boolean;
sn: string | null; sn: string | null;
givenName: string | null; givenName: string | null;

17
src/models/EmailTypes.ts Normal file
View File

@ -0,0 +1,17 @@
import {UserModel} from "@/models/AuthTypes.ts";
import {RecordModel} from "pocketbase";
export type EmailModel = {
sender: string;
recipients: string[];
subject: string;
content: string;
meta: object | null;
sentAt: string | null;
sentTo: string[] | null;
expand?: {
sender: UserModel | null;
recipients: UserModel[] | null;
sentTo: UserModel[] | null;
}
} & RecordModel

View File

@ -1,4 +1,4 @@
import {UserModal} from "./AuthTypes.ts"; import {UserModel} from "./AuthTypes.ts";
import {RecordModel} from "pocketbase"; import {RecordModel} from "pocketbase";
import {FieldEntries} from "@/components/formUtil/FromInput/types.ts"; import {FieldEntries} from "@/components/formUtil/FromInput/types.ts";
import {FormSchema} from "@/components/formUtil/formBuilder/types.ts"; import {FormSchema} from "@/components/formUtil/formBuilder/types.ts";
@ -17,10 +17,8 @@ export type EventModel = {
eventLinks: EventLink[]; eventLinks: EventLink[];
defaultEntryQuestionSchema: FormSchema | null; defaultEntryQuestionSchema: FormSchema | null;
defaultEntryStatusSchema: FormSchema | null; defaultEntryStatusSchema: FormSchema | null;
privilegedLists: string[];
expand?: { expand?: {
eventAdmins: UserModal[] | null; eventAdmins: UserModel[] | null;
privilegedLists: EventListModel[] | null;
} }
} & RecordModel } & RecordModel
@ -47,7 +45,6 @@ export type EventListModel = {
} }
} & RecordModel } & RecordModel
export type EventListSlotModel = { export type EventListSlotModel = {
eventList: string; eventList: string;
startDate: string; startDate: string;
@ -71,7 +68,7 @@ export type EventListSlotEntryModel = {
user: string | null user: string | null
expand?: { expand?: {
eventListsSlot: EventListSlotModel; eventListsSlot: EventListSlotModel;
user: UserModal | null; user: UserModel | null;
} }
} & RecordModel } & RecordModel
@ -90,7 +87,7 @@ export type EventListSlotEntriesWithUserModel =
expand?: { expand?: {
event: EventModel; event: EventModel;
eventList: EventListModel; eventList: EventListModel;
user: UserModal; user: UserModel;
} }
} }
& Pick<EventModel, "defaultEntryQuestionSchema" | "defaultEntryStatusSchema"> & Pick<EventModel, "defaultEntryQuestionSchema" | "defaultEntryStatusSchema">

View File

@ -1,5 +1,5 @@
import {RecordModel} from "pocketbase"; import {RecordModel} from "pocketbase";
import {UserModal} from "@/models/AuthTypes.ts"; import {UserModel} from "@/models/AuthTypes.ts";
import {EventListModel} from "@/models/EventTypes.ts"; import {EventListModel} from "@/models/EventTypes.ts";
export type MessagesModel = { export type MessagesModel = {
@ -20,8 +20,8 @@ export type MessagesModel = {
} | null } | null
expand: { expand: {
sender: UserModal sender: UserModel
recipients: UserModal[] recipients: UserModel[]
eventList: EventListModel | null eventList: EventListModel | null
repliedTo: MessagesModel | null repliedTo: MessagesModel | null
} }

View File

@ -1,5 +1,5 @@
import PocketBase, {RecordModel, RecordService} from "pocketbase"; import PocketBase, {RecordModel, RecordService} from "pocketbase";
import {LdapGroupModel, LdapSyncLogModel, UserModal} from "./AuthTypes.ts"; import {LdapGroupModel, LdapSyncLogModel, UserModel} from "./AuthTypes.ts";
import { import {
EventListModel, EventListModel,
EventListSlotEntriesWithUserModel, EventListSlotEntriesWithUserModel,
@ -9,6 +9,7 @@ import {
EventModel EventModel
} from "./EventTypes.ts"; } from "./EventTypes.ts";
import {MessagesModel} from "@/models/MessageTypes.ts"; import {MessagesModel} from "@/models/MessageTypes.ts";
import {EmailModel} from "@/models/EmailTypes.ts";
export type SettingsModel = { export type SettingsModel = {
key: ['privacyPolicy', 'agb', 'stexGroup', 'stuveEventQuestions'] key: ['privacyPolicy', 'agb', 'stexGroup', 'stuveEventQuestions']
@ -33,7 +34,7 @@ export interface TypedPocketBase extends PocketBase {
collection(idOrName: 'legalSettings'): RecordService<LegalSettingsModal> collection(idOrName: 'legalSettings'): RecordService<LegalSettingsModal>
collection(idOrName: 'users'): RecordService<UserModal> collection(idOrName: 'users'): RecordService<UserModel>
collection(idOrName: 'ldap_groups'): RecordService<LdapGroupModel> collection(idOrName: 'ldap_groups'): RecordService<LdapGroupModel>
@ -50,4 +51,6 @@ export interface TypedPocketBase extends PocketBase {
collection(idOrName: 'eventListSlotsWithEntriesCount'): RecordService<EventListSlotsWithEntriesCountModel> collection(idOrName: 'eventListSlotsWithEntriesCount'): RecordService<EventListSlotsWithEntriesCountModel>
collection(idOrName: 'eventListSlotEntriesWithUser'): RecordService<EventListSlotEntriesWithUserModel> collection(idOrName: 'eventListSlotEntriesWithUser'): RecordService<EventListSlotEntriesWithUserModel>
collection(idOrName: 'emails'): RecordService<EmailModel>
} }

View File

@ -1,127 +0,0 @@
import {Button, Center, Divider, Loader, Stack, Text, TextInput, ThemeIcon, UnstyledButton} from "@mantine/core";
import {NavLink} from "react-router-dom";
import classes from "@/pages/chat/EventListMessagesList.module.css";
import PBAvatar from "@/components/PBAvatar.tsx";
import {IconArrowDown, IconEdit, IconListSearch, IconMoodPuzzled, IconSpeakerphone} from "@tabler/icons-react";
import {usePB} from "@/lib/pocketbase.tsx";
import {useDebouncedState} from "@mantine/hooks";
import {useInfiniteQuery} from "@tanstack/react-query";
import {EventListModel, EventModel} from "@/models/EventTypes.ts";
const ListMessagesLink = ({eventList, event}: { eventList?: EventListModel, event?: EventModel }) => {
if (!eventList || !event) return null
return <UnstyledButton component={NavLink} to={`/chat/${eventList.id}`} className={classes.listLink}>
{
({isActive}) => <>
<PBAvatar model={eventList} name={eventList.name} img={eventList.img} size={"sm"}/>
<div className={classes.listText} data-active={isActive}>
{eventList.name} ({event.name})
</div>
</>
}
</UnstyledButton>
}
const AnnouncementsLink = () => {
return <div className={classes.announcementLinkContainer}>
<UnstyledButton component={NavLink} to={`/chat/announcements`} className={classes.announcementLink}>
{
({isActive}) => <>
<ThemeIcon variant="light" radius={"xl"} size="md" color="green">
<IconSpeakerphone/>
</ThemeIcon>
<div className={`${classes.listText} `} data-active={isActive}>
Ankündigungen
</div>
</>
}
</UnstyledButton>
<UnstyledButton component={NavLink} to={`/chat/send-announcements`} className={classes.announcementLink}>
{
({isActive}) => <>
<ThemeIcon variant="light" radius={"xl"} size="md" color="green">
<IconEdit/>
</ThemeIcon>
<div className={`${classes.listText} `} data-active={isActive}>
Gesendete Ankündigungen
</div>
</>
}
</UnstyledButton>
</div>
}
export default function EventListMessagesList() {
const {user, pb} = usePB()
const [chatEntriesQuery, setChatEntriesQuery] = useDebouncedState("", 200)
const query = useInfiniteQuery({
queryKey: ["chatEntries", chatEntriesQuery],
queryFn: async ({pageParam}) => (
await pb.collection("eventListSlotEntriesWithUser").getList(pageParam, 100, {
filter: `user='${user?.id}'&&eventList.enableChat=true&&listName~'${chatEntriesQuery}'`,
sort: "created",
expand: "event,eventList"
})
),
getNextPageParam: (lastPage) =>
lastPage.page >= lastPage.totalPages ? undefined : lastPage.page + 1,
initialPageParam: 1,
enabled: !!user,
})
const chatEntries = query.data?.pages.flatMap(t => t.items) || []
return <div className={`stack ${classes.container}`}>
<AnnouncementsLink/>
<Divider label={"Chats"}/>
<TextInput
leftSection={<IconListSearch/>}
rightSection={query.isPending ? <Loader size={"xs"}/> : undefined}
defaultValue={chatEntriesQuery}
onChange={(e) => setChatEntriesQuery(e.currentTarget.value)}
placeholder={"Nach Listen suchen..."}
/>
{chatEntries.length === 0 ? <Stack gap={"xs"} align={"center"}>
<ThemeIcon variant="transparent" size="xs" color="gray">
<IconMoodPuzzled/>
</ThemeIcon>
<Text size={"xs"} c={"dimmed"}>
{chatEntriesQuery ? "Keine Listen gefunden" : "Keine Listen"}
</Text>
</Stack> : (
<div className={`${classes.listsContainer} no-scrollbar`}>
{chatEntries.map(entry => <ListMessagesLink
key={entry.id}
eventList={entry.expand?.eventList}
event={entry.expand?.event}
/>)}
{query.hasNextPage && (
<Center p={"xs"}>
<Button
disabled={query.isFetchingNextPage || !query.hasNextPage}
loading={query.isFetchingNextPage}
variant={"transparent"}
size={"compact-xs"}
leftSection={<IconArrowDown/>}
onClick={() => query.fetchNextPage()}
>
Mehr laden
</Button>
</Center>
)}
</div>
)}
</div>
}

View File

@ -1,5 +0,0 @@
.backIcon {
@media (min-width: 768px) {
display: none;
}
}

View File

@ -1,92 +0,0 @@
import {Link, useParams} from "react-router-dom";
import {useQuery} from "@tanstack/react-query";
import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx";
import {ActionIcon, Button, Center, Collapse, Group, Loader, Text} from "@mantine/core";
import PBAvatar from "@/components/PBAvatar.tsx";
import {IconArrowRight, IconChevronLeft, IconInfoCircle, IconInfoCircleFilled} from "@tabler/icons-react";
import {useDisclosure} from "@mantine/hooks";
import Messages from "@/pages/chat/components/Messages.tsx";
import classes from './ListMessagesView.module.css';
import InnerHtml from "@/components/InnerHtml";
import {useEventRights} from "@/pages/events/util.ts";
export default function ListMessagesView() {
const {listId} = useParams() as { listId: string }
const {pb} = usePB()
const [showListInfo, showListInfoHandler] = useDisclosure(false)
const query = useQuery({
queryKey: ["eventListMessageView", listId],
queryFn: async () => (
await pb.collection("eventLists").getOne(listId, {expand: "event"})
)
})
const {canEditEvent} = useEventRights(query.data?.expand?.event)
if (query.isError) return (
<PocketBaseErrorAlert error={query.error}/>
)
if (query.isLoading || !query.data) return (
<Center>
<Loader/>
</Center>
)
const eventList = query.data
return <div className={"stack"}>
<Group wrap={"nowrap"}>
<ActionIcon
component={Link}
to={"/chat"}
variant={"transparent"} color={"blue"}
radius={"xl"}
aria-label={"Back to Threads"}
className={classes.backIcon}
>
<IconChevronLeft/>
</ActionIcon>
<PBAvatar model={eventList} name={eventList.name} img={eventList.img} size={"lg"}/>
<div>
<Text size={"lg"} fw={600}>{eventList.name}</Text>
<Text size={"xs"} c={"dimmed"}>{eventList.expand?.event.name}</Text>
</div>
<ActionIcon
variant={"transparent"} color={"blue"}
radius={"xl"} ms={"auto"}
aria-label={"Thread Info"}
onClick={showListInfoHandler.toggle}
>
{showListInfo ? <IconInfoCircleFilled/> : <IconInfoCircle/>}
</ActionIcon>
</Group>
<Collapse in={showListInfo}>
<div className={"stack"}>
<InnerHtml html={eventList.description ?? "Keine Beschreibung vorhanden"}/>
{
(canEditEvent || eventList.expand?.event.privilegedLists.includes(eventList.id))
&&
<Group>
<Button
rightSection={<IconArrowRight/>}
variant={"light"} color={"blue"}
size={"xs"}
component={Link}
to={`/events/e/${eventList.event}/lists/overview/${eventList.id}`}
>
Zur Liste
</Button>
</Group>
}
</div>
</Collapse>
<Messages eventList={eventList}/>
</div>
}

View File

@ -1,31 +0,0 @@
.announcement {
display: flex;
position: relative;
flex-direction: column;
background-color: var(--mantine-color-body);
padding: var(--padding);
border: var(--border);
border-color: var(--mantine-primary-color-5);
border-radius: var(--border-radius);
max-width: 100%;
word-break: break-all;
overflow-wrap: break-word;
}
.subject {
font-size: var(--mantine-font-size-lg);
font-weight: bold;
color: var(--mantine-primary-color-5);
margin: 0;
}
.subjectStack {
display: flex;
flex-direction: column;
justify-content: center;
}

View File

@ -1,85 +0,0 @@
import InnerHtml from "@/components/InnerHtml";
import classes from './Announcement.module.css'
import {Alert, Center, Group, List, Loader, Text, ThemeIcon, Tooltip} from "@mantine/core";
import {IconSpeakerphone} from "@tabler/icons-react";
import {MessagesModel} from "@/models/MessageTypes.ts";
import {getUserName} from "@/components/users/modals/util.tsx";
import {humanDeltaFromNow, pprintDate, pprintDateRange} from "@/lib/datetime.ts";
import {useQuery} from "@tanstack/react-query";
import {usePB} from "@/lib/pocketbase.tsx";
import {Link} from "react-router-dom";
export default function Announcement({announcement}: {
announcement: MessagesModel
}) {
const senderName = getUserName(announcement.expand.sender)
const eventId = announcement.meta?.event
const {pb, user} = usePB()
const entriesQuery = useQuery({
queryKey: ["eventListSlotEntries", "announcement", eventId],
queryFn: async () => {
return await pb.collection("eventListSlotEntriesWithUser").getList(1, 10, {
filter: `event='${eventId}'&&user='${user!.id}'`,
sort: "-created"
})
},
enabled: !!eventId
})
return <div className={classes.announcement}>
<Group justify={"space-between"} wrap={"nowrap"} align={"top"} mb={"md"}>
<div className={classes.subjectStack}>
{announcement.subject && <div className={`${classes.subject} wrapWords`}>
{announcement.subject}
</div>}
<Text size={"xs"} c={"dimmed"} className={"wrapWords"}>
von {senderName} {pprintDate(announcement.created)} {humanDeltaFromNow(announcement.created).message}
</Text>
</div>
<Tooltip label={`Ankündigung von ${getUserName(announcement.expand.sender)}`} withArrow>
<ThemeIcon
className={classes.icon}
variant={"transparent"} size={"sm"}
>
<IconSpeakerphone/>
</ThemeIcon>
</Tooltip>
</Group>
{(eventId && entriesQuery.data?.totalItems !== 0) && (
entriesQuery.isFetching ? <Center><Loader size={"xs"}/></Center> :
<Alert mb="md">
<Text>
Du hast bei dem Event
{" "}
<Text td={"underline"} component={Link} to={`/events/e/${eventId}`}>{
entriesQuery.data?.items[0].eventName
}</Text>
{" "}
{
entriesQuery.data?.totalItems === 1 ? "einen Eintrag" : `${entriesQuery.data?.totalItems} Einträge`
}
{"."}
</Text>
<List size={"sm"} c={"dimmed"}>
{entriesQuery.data?.items.map((entry) => (
<List.Item key={entry.id}>
{entry.listName}
{", Zeitslot "}
{pprintDateRange(entry.slotStartDate, entry.slotEndDate)}
{", "}
{humanDeltaFromNow(entry.slotStartDate).message}
</List.Item>
))}
</List>
<Text size={"sm"} td={"underline"} component={Link} to={"/events/entries"}>Hier</Text> kannst du
alle deine Einträge einsehen und bearbeiten.
</Alert>
)}
<InnerHtml html={announcement.content}/>
</div>
}

View File

@ -1,7 +0,0 @@
.announcements {
flex-grow: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--gap);
}

View File

@ -1,67 +0,0 @@
import {useInfiniteQuery} from "@tanstack/react-query";
import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx";
import {Button, Center, Loader, Text} from "@mantine/core";
import classes from './Announcements.module.css'
import {IconMessageCircleDown} from "@tabler/icons-react";
import Announcement from "@/pages/chat/components/Announcement.tsx";
export default function Announcements() {
const {user, pb} = usePB()
const query = useInfiniteQuery({
queryKey: ["announcements"],
queryFn: async ({pageParam}) => (
await pb.collection("messages").getList(pageParam, 50, {
filter: `isAnnouncement=true&&sender!='${user?.id}'`,
sort: "-created",
expand: "sender"
})
),
getNextPageParam: (lastPage) =>
lastPage.page >= lastPage.totalPages ? undefined : lastPage.page + 1,
initialPageParam: 1,
enabled: !!user,
})
const announcements = query.data?.pages.flatMap(page => page.items) || []
if (query.isError) return (
<PocketBaseErrorAlert error={query.error}/>
)
if (query.isLoading || !query.data) return (
<Center>
<Loader/>
</Center>
)
return <div className={`stack `}>
<div className={`scrollbar ${classes.announcements}`}>
{announcements.map((announcement) => (
<Announcement
key={announcement.id}
announcement={announcement}
/>
))}
{query.hasNextPage ? (
<Center>
<Button
variant={"transparent"} color={"blue"}
radius={"xl"}
onClick={() => query.fetchNextPage()}
leftSection={<IconMessageCircleDown/>}
loading={query.isFetchingNextPage}
>
Mehr laden
</Button>
</Center>
) : announcements.length === 0 ? <div className={classes.text}>
<Text ta={"center"} size={"xs"} c={"dimmed"}>
Noch keine Ankündigungen
</Text>
</div> : null
}
</div>
</div>
}

View File

@ -1,96 +0,0 @@
import {ActionIcon, Indicator} from "@mantine/core";
import {Link, useMatch} from "react-router-dom";
import {IconMessageCircle} from "@tabler/icons-react";
import {usePB} from "@/lib/pocketbase.tsx";
import {useEffect, useState} from "react";
import {useTimeout} from "@mantine/hooks";
import {MessagesModel} from "@/models/MessageTypes.ts";
import {APP_NAME, APP_URL} from "../../../../config.ts";
/**
* Parse HTML string and return text content
* @param html - HTML string
*/
const parseHtmlString = (html: string): string => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
return doc.body.textContent || '';
}
const sendDesktopNotification = (body: string, url: string) => {
if ("Notification" in window && Notification.permission === 'granted') {
const notification = new Notification(APP_NAME, {
body: body,
icon: `${APP_URL}/stuve-logo.svg`,
data: {url: url}
})
notification.onclick = () => {
window.focus()
window.open(notification.data.url);
}
}
}
export default function ChatNavIcon() {
const [notificationUrl, setNotificationUrl] = useState<string | null>(null);
const {start} = useTimeout(() => setNotificationUrl(null), 5000);
const {user, useSubscription} = usePB()
const match = useMatch("/chat/:listId")
useEffect(() => {
// Request notification permission on component mount
if ("Notification" in window && Notification.permission !== 'granted') {
Notification.requestPermission().then(() => {
sendDesktopNotification(
"Willkommen bei StuVe! Du kannst jetzt Desktop Benachrichtigungen erhalten.",
`${APP_URL}/chat`
)
})
}
}, [])
useSubscription<MessagesModel>({
idOrName: "messages",
topic: "*",
callback: (event) => {
if (event.action == "create") {
// check if chat page is not already open and if sender is not the user
if (match?.params.listId === event.record.eventList && event.record.sender === user?.id) {
return
}
const notificationUrl = `/chat/${event.record.isAnnouncement ? "announcements" : event.record.eventList}`
setNotificationUrl(notificationUrl)
start()
sendDesktopNotification(
parseHtmlString(event.record.content),
`${APP_URL}${notificationUrl}`
)
}
}
})
if (!user) {
return null
}
return <>
<Indicator inline processing disabled={notificationUrl === null} classNames={{
root: "stack"
}}>
<ActionIcon
component={Link}
to={notificationUrl ?? "/chat"}
variant={"transparent"}
color={"gray"}
>
<IconMessageCircle/>
</ActionIcon>
</Indicator>
</>
}

View File

@ -1,53 +0,0 @@
.messageContainer {
display: flex;
flex-grow: 1;
height: 100%;
overflow: hidden;
}
.messages {
flex-grow: 1;
overflow-y: auto;
display: flex;
flex-direction: column-reverse;
}
.message {
display: flex;
position: relative;
flex-direction: column;
background-color: var(--mantine-color-body);
padding: var(--padding);
border: var(--border);
border-radius: var(--mantine-radius-xl);
border-top-left-radius: var(--mantine-radius-sm);
margin: var(--gap);
max-width: fit-content;
word-break: break-all;
overflow-wrap: break-word;
&[data-sender="true"] {
align-self: flex-end;
border-radius: var(--mantine-radius-xl);
border-bottom-right-radius: var(--mantine-radius-sm);
& > .messageSender {
color: var(--mantine-primary-color-4);
}
}
}
.messageSender {
font-size: var(--mantine-font-size-xs);
color: var(--mantine-color-dimmed)
}
.text {
height: 100vh;
}

View File

@ -1,154 +0,0 @@
import {MessagesModel} from "@/models/MessageTypes.ts";
import TextEditor from "@/components/input/Editor";
import {useForm} from "@mantine/form";
import {ActionIcon, Button, Center, Divider, Group, Text} from "@mantine/core";
import {IconMessageCircleUp, IconSend} from "@tabler/icons-react";
import classes from './Messages.module.css'
import {useInfiniteQuery, useMutation} from "@tanstack/react-query";
import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx";
import InnerHtml from "@/components/InnerHtml";
import {getUserName} from "@/components/users/modals/util.tsx";
import {humanDeltaFromNow, pprintDateTime} from "@/lib/datetime.ts";
import {EventListModel} from "@/models/EventTypes.ts";
export default function Messages({eventList}: {
eventList: EventListModel
}) {
const {user, pb, useSubscription} = usePB()
const query = useInfiniteQuery({
queryKey: ["messages", eventList],
queryFn: async ({pageParam}) => (
await pb.collection("messages").getList(pageParam, 100, {
filter: `eventList='${eventList?.id}'`,
sort: "-created",
expand: "sender"
})
),
getNextPageParam: (lastPage) =>
lastPage.page >= lastPage.totalPages ? undefined : lastPage.page + 1,
initialPageParam: 1,
enabled: !!user,
})
useSubscription<MessagesModel>({
idOrName: "messages",
topic: "*",
callback: (event) => {
if (event.action == "create" && event.record.eventList == eventList.id) {
query.refetch()
}
}
})
const mutation = useMutation({
mutationFn: async () => {
await pb.collection("messages").create({
...formValues.values,
sender: user!.id,
eventList: eventList.id,
})
},
onSuccess: () => {
formValues.reset()
}
})
const formValues = useForm({
initialValues: {
content: ""
},
validate: {
content: (value) => {
if (value.length < 1) {
return "Bitte gib eine Nachricht ein"
}
},
}
})
const messages = query.data?.pages.flatMap(page => page.items) || []
return <div className={`stack ${classes.messageContainer}`}>
<PocketBaseErrorAlert error={query.error}/>
<div className={`${classes.messages} scrollbar`}>
{messages.map((message) => (
<div
className={classes.message}
key={message.id}
data-sender={message.sender === user?.id}
>
<div className={classes.messageSender}>
{getUserName(message.expand?.sender)}
</div>
<InnerHtml html={message.content}/>
{message.comment && <>
<Divider mt={"sm"} mb={"sm"} label="Kommentar"/>
<InnerHtml html={message.comment}/>
</>}
<div className={classes.messageSender}>
{humanDeltaFromNow(message.created).message} {pprintDateTime(message.created)}
</div>
</div>
))}
{query.hasNextPage ? (
<Center>
<Button
variant={"transparent"} color={"blue"}
radius={"xl"}
onClick={() => query.fetchNextPage()}
leftSection={<IconMessageCircleUp/>}
loading={query.isFetchingNextPage}
>
Mehr laden
</Button>
</Center>
) : <div className={classes.text}>
<Text ta={"center"} size={"xs"} c={"dimmed"}>
{
messages.length > 0 ?
"Keine weiteren Nachrichten"
: "Noch keine Nachrichten"
}
</Text>
</div>}
</div>
<PocketBaseErrorAlert error={mutation.error}/>
<form onSubmit={formValues.onSubmit(() => mutation.mutate())}>
<Group wrap={"nowrap"} align={"center"}>
<TextEditor
placeholder={"Neue Nachricht ..."}
style={{flexGrow: 1}}
noBorder hideToolbar
value={formValues.values.content}
onChange={(value) => formValues.setFieldValue("content", value)}
modEnter={() => {
if (!formValues.validate().hasErrors) {
mutation.mutate()
}
}}
/>
<div className={"stack"}>
<ActionIcon
variant={"transparent"} color={"blue"}
radius={"xl"} ms={"auto"}
bg={"var(--mantine-color-body)"}
size={"lg"}
aria-label={"Send"}
type={"submit"}
>
<IconSend size={16}/>
</ActionIcon>
</div>
</Group>
</form>
</div>
}

View File

@ -1,67 +0,0 @@
import {useInfiniteQuery} from "@tanstack/react-query";
import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx";
import {Button, Center, Loader, Text} from "@mantine/core";
import classes from './Announcements.module.css'
import {IconMessageCircleDown} from "@tabler/icons-react";
import Announcement from "@/pages/chat/components/Announcement.tsx";
export default function SendAnnouncements() {
const {user, pb} = usePB()
const query = useInfiniteQuery({
queryKey: ["announcements"],
queryFn: async ({pageParam}) => (
await pb.collection("messages").getList(pageParam, 50, {
filter: `isAnnouncement=true&&sender='${user?.id}'`,
sort: "-created",
expand: "sender"
})
),
getNextPageParam: (lastPage) =>
lastPage.page >= lastPage.totalPages ? undefined : lastPage.page + 1,
initialPageParam: 1,
enabled: !!user,
})
const announcements = query.data?.pages.flatMap(page => page.items) || []
if (query.isError) return (
<PocketBaseErrorAlert error={query.error}/>
)
if (query.isLoading || !query.data) return (
<Center>
<Loader/>
</Center>
)
return <div className={`stack `}>
<div className={`scrollbar ${classes.announcements}`}>
{announcements.map((announcement) => (
<Announcement
key={announcement.id}
announcement={announcement}
/>
))}
{query.hasNextPage ? (
<Center>
<Button
variant={"transparent"} color={"blue"}
radius={"xl"}
onClick={() => query.fetchNextPage()}
leftSection={<IconMessageCircleDown/>}
loading={query.isFetchingNextPage}
>
Mehr laden
</Button>
</Center>
) : announcements.length === 0 ? <div className={classes.text}>
<Text ta={"center"} size={"xs"} c={"dimmed"}>
Noch keine von dir gesendeten Ankündigungen
</Text>
</div> : null
}
</div>
</div>
}

View File

@ -1,24 +1,23 @@
import {Anchor, Breadcrumbs, Center} from "@mantine/core"; import {Anchor, Breadcrumbs, Center} from "@mantine/core";
import {Link, Outlet, Route, Routes} from "react-router-dom"; import {Link, Outlet, Route, Routes} from "react-router-dom";
import classes from "./ChatRouter.module.css"; import classes from "./EmailRouter.module.css";
import ConversationSvg from "@/illustrations/conversation.svg?react"; import EmailSVG from "@/illustrations/email.svg?react";
import {useMediaQuery} from "@mantine/hooks"; import {useMediaQuery} from "@mantine/hooks";
import EventListMessagesList from "@/pages/chat/EventListMessagesList.tsx"; import SentEmailsNavigation from "@/pages/email/SentEmailsNavigation.tsx";
import ListMessagesView from "@/pages/chat/ListMessagesView.tsx";
import {usePB} from "@/lib/pocketbase.tsx"; import {usePB} from "@/lib/pocketbase.tsx";
import {useLogin} from "@/components/users/modals/hooks.ts"; import {useLogin} from "@/components/users/modals/hooks.ts";
import Announcements from "@/pages/chat/components/Announcements.tsx";
import {useEffect} from "react"; import {useEffect} from "react";
import SendAnnouncements from "@/pages/chat/components/SendAnnouncements.tsx"; import NotFound from "@/pages/not-found/index.page.tsx";
import EmailView from "@/pages/email/EmailView";
const ChatIndex = () => { const EmailIndex = () => {
return <Center> return <Center>
<ConversationSvg width={"100%"} height={"100%"}/> <EmailSVG width={"100%"} height={"100%"}/>
</Center> </Center>
} }
export default function ChatRouter() { export default function EmailRouter() {
const isMobile = useMediaQuery("(max-width: 768px)") const isMobile = useMediaQuery("(max-width: 768px)")
const {user} = usePB() const {user} = usePB()
@ -38,7 +37,7 @@ export default function ChatRouter() {
<div className={"section-transparent"}> <div className={"section-transparent"}>
<Breadcrumbs>{[ <Breadcrumbs>{[
{title: "Home", to: "/"}, {title: "Home", to: "/"},
{title: "Nachrichten", to: "/chat"}, {title: "Email", to: "/email"},
].map(({title, to}) => ( ].map(({title, to}) => (
<Anchor component={Link} to={to} key={title} className={"wrapWords"}> <Anchor component={Link} to={to} key={title} className={"wrapWords"}>
{title} {title}
@ -50,14 +49,13 @@ export default function ChatRouter() {
<Outlet/> <Outlet/>
<Routes> <Routes>
<Route path={isMobile ? "/" : "*"} element={<EventListMessagesList/>}/> <Route path={isMobile ? "/" : "*"} element={<SentEmailsNavigation/>}/>
</Routes> </Routes>
<Routes> <Routes>
{!isMobile && <Route index element={<ChatIndex/>}/>} {!isMobile && <Route index element={<EmailIndex/>}/>}
<Route path={":listId"} element={<ListMessagesView/>}/> <Route path={":emailId"} element={<EmailView/>}/>
<Route path={"announcements"} element={<Announcements/>}/> <Route path={"*"} element={<NotFound/>}/>
<Route path={"send-announcements"} element={<SendAnnouncements/>}/>
</Routes> </Routes>
</div> </div>
</div> </div>

View File

@ -0,0 +1,7 @@
.infoText {
color: var(--mantine-color-dimmed);
}
.container {
overflow: auto !important;
}

View File

@ -0,0 +1,160 @@
import {useParams} from "react-router-dom";
import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx";
import {useQuery} from "@tanstack/react-query";
import {Center, Divider, List, Loader, rem, ScrollArea, ThemeIcon, Title, Tooltip} from "@mantine/core";
import {pprintDate, pprintTime} from "@/lib/datetime.ts";
import InnerHtml from "@/components/InnerHtml";
import {IconCheck, IconChecks, IconCircleCheck, IconX} from "@tabler/icons-react";
import TextWithIcon from "@/components/layout/TextWithIcon";
import ShowHelp from "@/components/ShowHelp.tsx";
import {getUserName} from "@/components/users/modals/util.tsx";
import classes from "./index.module.css";
export default function EmailView() {
const {emailId} = useParams() as { emailId: string }
const {pb, useSubscription} = usePB()
const query = useQuery({
queryKey: ["email", emailId],
queryFn: async () => (
await pb.collection("emails").getOne(emailId, {expand: "sender,recipients,sentTo"})
)
})
useSubscription({
idOrName: "emails",
topic: emailId,
callback: () => query.refetch()
}, [emailId])
if (query.isError) return (
<PocketBaseErrorAlert error={query.error}/>
)
if (query.isLoading || !query.data) return (
<Center>
<Loader/>
</Center>
)
const email = query.data
const wasSentToCount = email.recipients.filter(r => email.sentTo?.includes(r)).length
return <div className={`stack scrollable scrollbar ${classes.container}`}>
<div className={"section"}>
{
email.sentAt && wasSentToCount === 0 ? <>
<Tooltip
label={"Nicht gesendet"}
color={"red"}
>
<ThemeIcon
className={"section-icon"}
color={"red"}
variant={"light"}
radius={"xl"}
>
<IconX/>
</ThemeIcon>
</Tooltip>
</> : email.sentAt ? <>
<Tooltip
label={`Versendet am ${pprintDate(email.sentAt)} um ${pprintTime(email.sentAt)}`}
color={"green"}
>
<ThemeIcon
className={"section-icon"}
color={"green"}
variant={"light"}
radius={"xl"}
>
<IconCheck/>
</ThemeIcon>
</Tooltip>
</> : <>
<Tooltip label={"Status: In Bearbeitung"}>
<Loader className={"section-icon"} size={"xs"}/>
</Tooltip>
</>}
<div className={"stack"}>
<Title order={1}>
{email.subject}
</Title>
<TextWithIcon icon={
<Tooltip label={"Gesendet an"}>
<IconChecks/>
</Tooltip>
}>
{wasSentToCount} gesendet / {email.recipients.length} insgesamt
</TextWithIcon>
</div>
</div>
<div className={"section stack"}>
<Title order={2}>
Empfängerinnen
</Title>
<ShowHelp>
Das versenden einer Email kann einige Zeit in Anspruch nehmen.
<br/>
Falls eine Email an einzelne Personen nicht gesendet wurde, kann es sein,
dass deren Email-Adresse inkorrekt ist.
</ShowHelp>
<ScrollArea.Autosize mah={300}>
<List
spacing="xs"
size="sm"
center
icon={
<ThemeIcon color="teal" size={24} radius="xl">
<IconCircleCheck style={{width: rem(16), height: rem(16)}}/>
</ThemeIcon>
}
>
{email.expand?.recipients?.map(r => {
const wasSent = email.sentTo?.includes(r.id)
const inProgress = !email.sentAt
return <>
<List.Item
key={r.id}
icon={
<Tooltip
label={wasSent ? "Gesendet" : inProgress ? "Wird gesendet ..." : "Fehler: Konnte nicht gesendet werden"}>
<ThemeIcon
variant={"transparent"}
color={wasSent ? "green" : inProgress ? "blue" : "red"}
>
{
wasSent ? <IconCheck/> : inProgress ? <Loader size={"xs"}/> : <IconX/>
}
</ThemeIcon>
</Tooltip>
}
>
{getUserName(r)}
</List.Item>
</>
})}
</List>
</ScrollArea.Autosize>
</div>
<div className={"section stack"}>
<Title order={2}>
Inhalt
</Title>
<Divider/>
<InnerHtml html={email.content}/>
</div>
</div>
}

View File

@ -3,27 +3,30 @@
transition: width 0.3s ease transition: width 0.3s ease
} }
.announcementLinkContainer { .newEmailBtnContainer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: calc(var(--padding) / 2); padding: calc(var(--padding) / 2);
border: var(--border); border: var(--border);
border-radius: var(--border-radius); border-radius: var(--border-radius);
background-color: var(--mantine-color-body); background-color: var(--mantine-color-body);
&[data-active="true"] {
border-color: var(--mantine-primary-color-5);
}
} }
.announcementLink { .newEmailBtn {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
padding: calc(var(--padding) / 2);
gap: var(--gap); gap: var(--gap);
} }
.listText { .emailSubject {
color: var(--mantine-color-dimmed);
width: calc(100%);
width: calc(100% - 40px);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@ -34,15 +37,23 @@
} }
} }
.listLink { .emailSentDate {
display: flex; color: var(--mantine-color-dimmed);
flex-direction: row; font-size: var(--mantine-font-size-xs);
align-items: center;
padding: calc(var(--padding) / 2); &[data-active="true"] {
gap: var(--gap); color: var(--mantine-primary-color-5);
}
} }
.listsContainer { .emailLink {
display: flex;
flex-direction: column;
align-items: start;
padding: calc(var(--padding) / 2);
}
.emailListContainer {
//flex-grow: 1; //flex-grow: 1;
overflow: auto; overflow: auto;
border: var(--border); border: var(--border);

View File

@ -0,0 +1,118 @@
import {Button, Center, Divider, Loader, Stack, Text, TextInput, ThemeIcon, UnstyledButton} from "@mantine/core";
import {NavLink} from "react-router-dom";
import classes from "@/pages/email/SentEmailsNavigation.module.css";
import {IconArrowDown, IconListSearch, IconMailPlus, IconMoodPuzzled} from "@tabler/icons-react";
import {usePB} from "@/lib/pocketbase.tsx";
import {useDebouncedState, useDisclosure} from "@mantine/hooks";
import {useInfiniteQuery} from "@tanstack/react-query";
import {EmailModal} from "@/components/EmailModal";
import {EmailModel} from "@/models/EmailTypes.ts";
import {pprintDateTime} from "@/lib/datetime.ts";
const EmailLink = ({email}: { email?: EmailModel }) => {
if (!email) return null
return <UnstyledButton component={NavLink} to={`/email/${email.id}`} className={classes.emailLink}>
{
({isActive}) => <>
<div className={classes.emailSubject} data-active={isActive}>
{email.subject}
</div>
<div className={classes.emailSentDate} data-active={isActive}>
{pprintDateTime(email.created)}
</div>
</>
}
</UnstyledButton>
}
const NewEmailButton = () => {
const [showModal, showModalHandler] = useDisclosure(false)
return <>
<EmailModal opened={showModal} onClose={showModalHandler.close}/>
<div className={classes.newEmailBtnContainer} data-active={showModal}>
<UnstyledButton className={classes.newEmailBtn} onClick={showModalHandler.toggle}>
<ThemeIcon
variant="light"
radius={"xl"}
size="md"
color={showModal ? "blue" : "green"}
>
<IconMailPlus/>
</ThemeIcon>
<div className={`${classes.emailSubject} `}>
Neue Email
</div>
</UnstyledButton>
</div>
</>
}
export default function SentEmailsNavigation() {
const {user, pb} = usePB()
const [emailQuery, setEmailQuery] = useDebouncedState("", 200)
const query = useInfiniteQuery({
queryKey: ["emails", emailQuery],
queryFn: async ({pageParam}) => (
await pb.collection("emails").getList(pageParam, 100, {
filter: `sender='${user?.id}'&&(content~'${emailQuery}' || subject~'${emailQuery}')`,
sort: "-created",
})
),
getNextPageParam: (lastPage) =>
lastPage.page >= lastPage.totalPages ? undefined : lastPage.page + 1,
initialPageParam: 1,
enabled: !!user,
})
const emails = query.data?.pages.flatMap(t => t.items) || []
return <div className={`stack ${classes.container}`}>
<NewEmailButton/>
<Divider label={"Gesendete Emails"}/>
<TextInput
leftSection={<IconListSearch/>}
rightSection={query.isPending ? <Loader size={"xs"}/> : undefined}
defaultValue={emailQuery}
onChange={(e) => setEmailQuery(e.currentTarget.value)}
placeholder={"Nach Emails suchen..."}
/>
{emails.length === 0 ? <Stack gap={"xs"} align={"center"}>
<ThemeIcon variant="transparent" size="xs" color="gray">
<IconMoodPuzzled/>
</ThemeIcon>
<Text size={"xs"} c={"dimmed"}>
{emailQuery ? "Keine Emails gefunden" : "Du hast bisher keine Emails gesendet"}
</Text>
</Stack> : (
<div className={`${classes.emailListContainer} no-scrollbar`}>
{emails.map(entry => <EmailLink
key={entry.id}
email={entry}
/>)}
{query.hasNextPage && (
<Center p={"xs"}>
<Button
disabled={query.isFetchingNextPage || !query.hasNextPage}
loading={query.isFetchingNextPage}
variant={"transparent"}
size={"compact-xs"}
leftSection={<IconArrowDown/>}
onClick={() => query.fetchNextPage()}
>
Mehr laden
</Button>
</Center>
)}
</div>
)}
</div>
}

View File

@ -19,11 +19,11 @@ export default function EventNavigate() {
const eventQuery = useQuery({ const eventQuery = useQuery({
queryKey: ["event", eventId], queryKey: ["event", eventId],
queryFn: async () => (await pb.collection("events").getOne(eventId, { queryFn: async () => (await pb.collection("events").getOne(eventId, {
expand: "eventAdmins, privilegedLists, privilegedLists.eventListSlots_via_eventList.eventListSlotEntries_via_eventListsSlot" expand: "eventAdmins"
})) }))
}) })
const {isPrivilegedUser, canEditEvent} = useEventRights(eventQuery.data) const { canEditEvent} = useEventRights(eventQuery.data)
if (eventQuery.isLoading) { if (eventQuery.isLoading) {
return <LoadingOverlay/> return <LoadingOverlay/>
@ -37,9 +37,5 @@ export default function EventNavigate() {
return <Navigate to={`/events/e/${eventId}`} replace/> return <Navigate to={`/events/e/${eventId}`} replace/>
} }
if (isPrivilegedUser) {
return <Navigate to={`/events/e/${eventId}/lists`} replace/>
}
return <Navigate to={`/events/s/${eventId}`} replace/> return <Navigate to={`/events/s/${eventId}`} replace/>
} }

View File

@ -4,7 +4,7 @@ import {DateTimePicker} from "@mantine/dates";
import {IconCheck, IconInfoCircle, IconX} from "@tabler/icons-react"; import {IconCheck, IconInfoCircle, IconX} from "@tabler/icons-react";
import {useMutation} from "@tanstack/react-query"; import {useMutation} from "@tanstack/react-query";
import dayjs from "dayjs"; import dayjs from "dayjs";
import {UserModal} from "@/models/AuthTypes.ts"; import {UserModel} from "@/models/AuthTypes.ts";
import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx"; import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx";
import UserInput from "@/components/users/UserInput.tsx"; import UserInput from "@/components/users/UserInput.tsx";
import {EventModel} from "@/models/EventTypes.ts"; import {EventModel} from "@/models/EventTypes.ts";
@ -33,13 +33,13 @@ export default function CreateEvent({onSuccess, onAbort}: {
name: "", name: "",
startDate: null, startDate: null,
endDate: null, endDate: null,
eventAdmins: [user] as UserModal[], eventAdmins: [user] as UserModel[],
isStuveEvent: true, isStuveEvent: true,
}, },
validate: { validate: {
name: hasLength({min: 4, max: 50}, 'Der Name muss zwischen 4 und 50 Zeichen lang sein.'), name: hasLength({min: 4, max: 50}, 'Der Name muss zwischen 4 und 50 Zeichen lang sein.'),
startDate: (value) => dayjs(value).isAfter(dayjs(), "day") ? null : "Das Startdatum muss in der Zukunft liegen.", startDate: (value) => dayjs(value).isAfter(dayjs(), "day") ? null : "Es können nur Events in der Zukunft erstellt werden.",
endDate: (value, values) => dayjs(value).isAfter(dayjs(values.startDate), "day") ? null : "Das Enddatum muss nach dem Startdatum liegen.", endDate: (value, values) => dayjs(value).isAfter(dayjs(values.startDate), "day") || dayjs(value).isSame(dayjs(values.startDate), "day") ? null : "Ungültiges Enddatum.",
eventAdmins: (value) => value.length > 0 ? null : "Es muss mindestens ein Admin ausgewählt werden.", eventAdmins: (value) => value.length > 0 ? null : "Es muss mindestens ein Admin ausgewählt werden.",
} }
}) })

View File

@ -24,7 +24,6 @@ import {
IconHistory, IconHistory,
IconHistoryOff, IconHistoryOff,
IconInfoCircle, IconInfoCircle,
IconList,
IconSearch, IconSearch,
IconSortAscending, IconSortAscending,
IconSortDescending, IconSortDescending,
@ -43,7 +42,7 @@ import {useEventRights} from "@/pages/events/util.ts";
*/ */
const EventRow = ({event}: { event: EventModel }) => { const EventRow = ({event}: { event: EventModel }) => {
const {isPrivilegedUser, canEditEvent} = useEventRights(event) const {canEditEvent} = useEventRights(event)
const [opened, handlers] = useDisclosure(false) const [opened, handlers] = useDisclosure(false)
@ -108,26 +107,14 @@ const EventRow = ({event}: { event: EventModel }) => {
<IconUserStar/> <IconUserStar/>
</ThemeIcon> </ThemeIcon>
</Tooltip> </Tooltip>
) : isPrivilegedUser ? ) : (
<Tooltip label={"Du kannst das Event einsehen"} position={"left"} color={"green"} <ThemeIcon
withArrow> variant={"transparent"}
<ThemeIcon aria-label={"spacer"}
color={"green"} size={"xs"}
variant={"transparent"} mr={"sm"}
aria-label={"you are a privileged user"} />
size={"xs"} )
mr={"sm"}
>
<IconList/>
</ThemeIcon>
</Tooltip> : (
<ThemeIcon
variant={"transparent"}
aria-label={"spacer"}
size={"xs"}
mr={"sm"}
/>
)
) )
} }
@ -187,7 +174,6 @@ export const EventList = () => {
return await pb.collection("events").getList(activePage, 10, { return await pb.collection("events").getList(activePage, 10, {
sort: sort, sort: sort,
filter: [`hideFromPublic = false`, ...filter].join(" && "), filter: [`hideFromPublic = false`, ...filter].join(" && "),
expand: "privilegedLists.eventListSlots_via_eventList.eventListSlotEntries_via_eventListsSlot"
} }
) )
} }

View File

@ -15,11 +15,11 @@ export default function StatusEditor() {
const eventQuery = useQuery({ const eventQuery = useQuery({
queryKey: ["event", eventId], queryKey: ["event", eventId],
queryFn: async () => (await pb.collection("events").getOne(eventId, { queryFn: async () => (await pb.collection("events").getOne(eventId, {
expand: "eventAdmins, privilegedLists, privilegedLists.eventListSlots_via_eventList.eventListSlotEntries_via_eventListsSlot" expand: "eventAdmins"
})) }))
}) })
const {isPrivilegedUser, canEditEvent} = useEventRights(eventQuery.data) const {canEditEvent} = useEventRights(eventQuery.data)
if (eventQuery.isLoading) { if (eventQuery.isLoading) {
return <LoadingOverlay/> return <LoadingOverlay/>
@ -29,7 +29,7 @@ export default function StatusEditor() {
return <NotFound/> return <NotFound/>
} }
if (!(canEditEvent || isPrivilegedUser)) { if (!(canEditEvent)) {
return <Navigate to={`/events/s/${eventId}`} replace/> return <Navigate to={`/events/s/${eventId}`} replace/>
} }

View File

@ -89,11 +89,11 @@ export default function EditEventRouter() {
const eventQuery = useQuery({ const eventQuery = useQuery({
queryKey: ["event", eventId], queryKey: ["event", eventId],
queryFn: async () => (await pb.collection("events").getOne(eventId, { queryFn: async () => (await pb.collection("events").getOne(eventId, {
expand: "eventAdmins, privilegedLists, privilegedLists.eventListSlots_via_eventList.eventListSlotEntries_via_eventListsSlot" expand: "eventAdmins"
})) }))
}) })
const {isPrivilegedUser, canEditEvent} = useEventRights(eventQuery.data) const {canEditEvent} = useEventRights(eventQuery.data)
if (eventQuery.isLoading) { if (eventQuery.isLoading) {
return <LoadingOverlay/> return <LoadingOverlay/>
@ -103,7 +103,7 @@ export default function EditEventRouter() {
return <NotFound/> return <NotFound/>
} }
if (!(canEditEvent || isPrivilegedUser)) { if (!(canEditEvent)) {
return <Navigate to={`/events/s/${eventId}`} replace/> return <Navigate to={`/events/s/${eventId}`} replace/>
} }

View File

@ -1,9 +1,9 @@
import {EventModel} from "@/models/EventTypes.ts"; import {EventModel} from "@/models/EventTypes.ts";
import classes from "../EditEventRouter.module.css"; import classes from "../EditEventRouter.module.css";
import {IconCalendar, IconHourglass, IconList, IconMap, IconSparkles} from "@tabler/icons-react"; import {IconCalendar, IconHourglass, IconMap, IconSparkles} from "@tabler/icons-react";
import {areDatesSame, humanDeltaFromNow, pprintDate} from "@/lib/datetime.ts"; import {areDatesSame, humanDeltaFromNow, pprintDate} from "@/lib/datetime.ts";
import UsersDisplay from "@/components/users/UsersDisplay.tsx"; import UsersDisplay from "@/components/users/UsersDisplay.tsx";
import {List, Text, ThemeIcon, Title, Tooltip} from "@mantine/core"; import {Text, ThemeIcon, Title, Tooltip} from "@mantine/core";
/** /**
* Displays the event data * Displays the event data
@ -86,21 +86,6 @@ export default function EventData({event, hideHeader}: { event: EventModel, hide
</div> </div>
</Tooltip> </Tooltip>
} }
{
event.expand?.privilegedLists &&
<Tooltip label={"Privilegierte Listen"} position={"top-start"}>
<List size={"sm"} icon={<IconList size={10}/>}>
{
event.expand?.privilegedLists.map((l) => (
<List.Item key={l.id} fw={500}>
{l.name}
</List.Item>
))
}
</List>
</Tooltip>
}
</div> </div>
</div> </div>
) )

View File

@ -1,6 +1,6 @@
import {EventListModel, EventModel} from "@/models/EventTypes.ts"; import {EventModel} from "@/models/EventTypes.ts";
import {useForm} from "@mantine/form"; import {useForm} from "@mantine/form";
import {UserModal} from "@/models/AuthTypes.ts"; import {UserModel} from "@/models/AuthTypes.ts";
import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx"; import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx";
import {Button, Group, Title} from "@mantine/core"; import {Button, Group, Title} from "@mantine/core";
import UserInput from "@/components/users/UserInput.tsx"; import UserInput from "@/components/users/UserInput.tsx";
@ -8,7 +8,6 @@ import {useMutation} from "@tanstack/react-query";
import {queryClient} from "@/main.tsx"; import {queryClient} from "@/main.tsx";
import {showSuccessNotification} from "@/components/util.tsx"; import {showSuccessNotification} from "@/components/util.tsx";
import ShowHelp from "@/components/ShowHelp.tsx"; import ShowHelp from "@/components/ShowHelp.tsx";
import ListSelect from "@/pages/events/e/:eventId/EventLists/ListSelect.tsx";
/** /**
* This component allows the user to edit the admins of the event and the event lists. * This component allows the user to edit the admins of the event and the event lists.
@ -20,8 +19,7 @@ export default function EditEventMembers({event}: { event: EventModel }) {
const formValues = useForm({ const formValues = useForm({
initialValues: { initialValues: {
eventAdmins: event?.expand?.eventAdmins ?? [user] as UserModal[], eventAdmins: event?.expand?.eventAdmins ?? [user] as UserModel[],
privilegedLists: event?.expand?.privilegedLists ?? [] as EventListModel[]
}, },
validate: { validate: {
eventAdmins: (value) => value.length > 0 ? null : "Es muss mindestens ein Admin ausgewählt werden.", eventAdmins: (value) => value.length > 0 ? null : "Es muss mindestens ein Admin ausgewählt werden.",
@ -32,7 +30,6 @@ export default function EditEventMembers({event}: { event: EventModel }) {
mutationFn: async () => { mutationFn: async () => {
return await pb.collection("events").update(event.id, { return await pb.collection("events").update(event.id, {
eventAdmins: [...formValues.values.eventAdmins.map((member) => member.id), user?.id], eventAdmins: [...formValues.values.eventAdmins.map((member) => member.id), user?.id],
privilegedLists: formValues.values.privilegedLists.map((member) => member.id)
}) })
}, },
onSuccess: () => { onSuccess: () => {
@ -45,20 +42,8 @@ export default function EditEventMembers({event}: { event: EventModel }) {
<Title order={4} c={"blue"}>Event Admins</Title> <Title order={4} c={"blue"}>Event Admins</Title>
<ShowHelp> <ShowHelp>
<Title order={6}>Event Admins</Title>
Event Admin können alle Einstellungen des Events bearbeiten. Event Admin können alle Einstellungen des Events bearbeiten.
Sie können auch alle Listen bearbeiten und verwalten. Sie können auch alle Listen bearbeiten und verwalten.
<br/>
<br/>
<Title order={6}>Privilegierte Listen</Title>
Alle Teilnehmenden in privilegierten Listen können alle Anmeldungen von allen Listen
dieses Events sehen und den Status von allen Teilnehmenden bearbeiten.
<br/>
Du kannst eine privilegierte Liste z.B. für die Event-Orgs erstellen, so dass diese
alle Anmeldungen sehen und bearbeiten können.
<br/>
<br/>
</ShowHelp> </ShowHelp>
<PocketBaseErrorAlert error={editMutation.error}/> <PocketBaseErrorAlert error={editMutation.error}/>
@ -72,14 +57,6 @@ export default function EditEventMembers({event}: { event: EventModel }) {
setSelectedRecords={(records) => formValues.setFieldValue("eventAdmins", records)} setSelectedRecords={(records) => formValues.setFieldValue("eventAdmins", records)}
/> />
<ListSelect
label={"Privilegierte Listen"}
description={"Teilnehmende in privilegierten Listen können alle Event-Anmeldungen sehen und deren Status bearbeiten."}
event={event}
selectedRecords={formValues.values.privilegedLists}
setSelectedRecords={(records) => formValues.setFieldValue("privilegedLists", records)}
/>
<Group> <Group>
<Button <Button
type={"submit"} type={"submit"}

View File

@ -6,7 +6,6 @@ import {
IconForms, IconForms,
IconLock, IconLock,
IconLockOpen, IconLockOpen,
IconMessageCircle,
IconSettings, IconSettings,
IconUserCog IconUserCog
} from "@tabler/icons-react"; } from "@tabler/icons-react";
@ -58,14 +57,6 @@ export default function EventListRouter({event}: { event: EventModel }) {
} }
] ]
if (list.enableChat) {
nav.push({
icon: <IconMessageCircle/>,
to: `/chat/${list.id}`,
title: "Chat"
})
}
if (canEditEvent) { if (canEditEvent) {
nav.push(...[ nav.push(...[
{ {

View File

@ -28,7 +28,7 @@ export default function ListEntryQuestionSettings({list, event}: { list: EventLi
}) })
}, },
onSuccess: () => { onSuccess: () => {
showSuccessNotification("Fragen und Liste gespeichert") showSuccessNotification("Formular Fragen und Liste gespeichert")
return queryClient.invalidateQueries({queryKey: ["event", event.id, "list", list.id]}) return queryClient.invalidateQueries({queryKey: ["event", event.id, "list", list.id]})
} }
}) })
@ -39,7 +39,7 @@ export default function ListEntryQuestionSettings({list, event}: { list: EventLi
<PocketBaseErrorAlert error={editMutation.error}/> <PocketBaseErrorAlert error={editMutation.error}/>
{(!list.ignoreDefaultEntryQuestionScheme && event.defaultEntryQuestionSchema) && ( {(!list.ignoreDefaultEntryQuestionScheme && event.defaultEntryQuestionSchema) && (
<Alert color={"blue"} title={"Standard Fragen"} className={"stack"}> <Alert color={"blue"} title={"Standard Formular Fragen"} className={"stack"}>
<Text c={"dimmed"} mb={"sm"} size={"xs"}> <Text c={"dimmed"} mb={"sm"} size={"xs"}>
Folgende Fragen sind standardmäßig für dieses Event vorgesehen Folgende Fragen sind standardmäßig für dieses Event vorgesehen
und werden automatisch angehängt: und werden automatisch angehängt:

View File

@ -21,7 +21,6 @@ export default function ListSettings({list, event}: { list: EventListModel, even
open: list.open, open: list.open,
allowOverlappingEntries: list.allowOverlappingEntries, allowOverlappingEntries: list.allowOverlappingEntries,
onlyStuVeAccounts: list.onlyStuVeAccounts, onlyStuVeAccounts: list.onlyStuVeAccounts,
enableChat: list.enableChat,
favorite: list.favorite, favorite: list.favorite,
description: list.description || "", description: list.description || "",
} }
@ -106,19 +105,6 @@ export default function ListSettings({list, event}: { list: EventListModel, even
{...formValues.getInputProps("allowOverlappingEntries", {type: "checkbox"})} {...formValues.getInputProps("allowOverlappingEntries", {type: "checkbox"})}
/> />
<CheckboxCard
label={"Chat aktivieren"}
description={
"Du kannst eine neue Chatgruppe für diese Liste erstellen. " +
"Alle Anmeldungen der Liste können diesen Chat nutzen. " +
"Eventadmins können den Chat auch nutzen und alle Nachrichten bearbeiten sowie löschen. " +
"Neue Anmeldungen sehen auch vergangene Nachrichten.<br/>" +
"Der Chat wird automatisch gelöscht, wenn diese Liste gelöscht wird. Wenn du den Chat deaktivierst, " +
"werden die Nachrichten nicht gelöscht und kann der Chat kann später wieder aktiviert werden."
}
{...formValues.getInputProps("enableChat", {type: "checkbox"})}
/>
<TextEditor <TextEditor
maxHeight={"500px"} maxHeight={"500px"}
fullToolbar fullToolbar

View File

@ -13,7 +13,9 @@ import {
} from "@/pages/events/e/:eventId/EventLists/EventListComponents/UpdateEntryStatusModal.tsx"; } from "@/pages/events/e/:eventId/EventLists/EventListComponents/UpdateEntryStatusModal.tsx";
import {MoveEntryModal} from "@/pages/events/e/:eventId/EventLists/EventListComponents/MoveEntryModal.tsx"; import {MoveEntryModal} from "@/pages/events/e/:eventId/EventLists/EventListComponents/MoveEntryModal.tsx";
import EntryStatusSpoiler from "@/pages/events/e/:eventId/EventLists/EventListComponents/EntryStatusSpoiler.tsx"; import EntryStatusSpoiler from "@/pages/events/e/:eventId/EventLists/EventListComponents/EntryStatusSpoiler.tsx";
import {getListSchemas, useEventRights} from "@/pages/events/util.ts"; import {getEventMailDefaultText, getListSchemas, useEventRights} from "@/pages/events/util.ts";
import {UserModel} from "@/models/AuthTypes.ts";
import {EmailModal} from "@/components/EmailModal";
export default function EditSlotEntryMenu({entry, refetch, event}: { export default function EditSlotEntryMenu({entry, refetch, event}: {
@ -30,6 +32,9 @@ export default function EditSlotEntryMenu({entry, refetch, event}: {
const [showMoveEntryModal, showMoveEntryModalHandler] = useDisclosure(false) const [showMoveEntryModal, showMoveEntryModalHandler] = useDisclosure(false)
const [showEmailModal, showEmailModalHandler] = useDisclosure(false)
const deleteEntryMutation = useMutation({ const deleteEntryMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
await pb.collection("eventListSlotEntries").delete(entry.id) await pb.collection("eventListSlotEntries").delete(entry.id)
@ -51,6 +56,15 @@ export default function EditSlotEntryMenu({entry, refetch, event}: {
const noStatusField = statusSchema.fields.length === 0 const noStatusField = statusSchema.fields.length === 0
return <> return <>
<EmailModal
opened={showEmailModal}
onClose={showEmailModalHandler.close}
disableUserInput={true}
recipients={[entry.expand?.user] as UserModel[]}
content={getEventMailDefaultText(event)}
/>
<ConfirmModal/> <ConfirmModal/>
<UpdateEntryStatusModal <UpdateEntryStatusModal
@ -97,7 +111,7 @@ export default function EditSlotEntryMenu({entry, refetch, event}: {
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item <Menu.Item
leftSection={<IconSend size={16}/>} leftSection={<IconSend size={16}/>}
disabled onClick={showEmailModalHandler.toggle}
> >
Person benachrichtigen Person benachrichtigen
</Menu.Item> </Menu.Item>

View File

@ -27,7 +27,7 @@ export default function EditDefaultEntryQuestionSchema({event}: { event: EventMo
}) })
}, },
onSuccess: () => { onSuccess: () => {
showSuccessNotification("Fragen gespeichert") showSuccessNotification("Formular Fragen gespeichert")
return queryClient.invalidateQueries({queryKey: ["event", event.id]}) return queryClient.invalidateQueries({queryKey: ["event", event.id]})
} }
}) })

View File

@ -1,10 +1,8 @@
import {EventListModel, EventListSlotEntriesWithUserModel, EventModel} from "@/models/EventTypes.ts"; import {EventListModel, EventListSlotEntriesWithUserModel, EventModel} from "@/models/EventTypes.ts";
import {ListResult} from "pocketbase"; import {useMutation} from "@tanstack/react-query";
import {InfiniteData, UseInfiniteQueryResult, useMutation} from "@tanstack/react-query"; import {Button, Checkbox, Fieldset, Group, Modal, Select, Text} from "@mantine/core";
import {Button, Checkbox, Fieldset, Group, Modal, Progress, Select, Text} from "@mantine/core";
import ShowHelp from "@/components/ShowHelp.tsx"; import ShowHelp from "@/components/ShowHelp.tsx";
import {useForm} from "@mantine/form"; import {useForm} from "@mantine/form";
import {useEffect} from "react";
import {FormSchema} from "@/components/formUtil/formBuilder/types.ts"; import {FormSchema} from "@/components/formUtil/formBuilder/types.ts";
import {IconDownload} from "@tabler/icons-react"; import {IconDownload} from "@tabler/icons-react";
import {useShowDebug} from "@/components/ShowDebug.tsx"; import {useShowDebug} from "@/components/ShowDebug.tsx";
@ -22,12 +20,12 @@ type FormValues = {
charset: typeof CSV_CHARSETS[number] charset: typeof CSV_CHARSETS[number]
} }
export default function DownloadDataModal({opened, onClose, lists, event, query}: { export default function DownloadDataModal({opened, onClose, lists, event, entries}: {
opened: boolean opened: boolean
onClose: () => void onClose: () => void
event: EventModel event: EventModel
lists: EventListModel[] lists: EventListModel[]
query: UseInfiniteQueryResult<InfiniteData<ListResult<EventListSlotEntriesWithUserModel>, unknown>, Error> entries: EventListSlotEntriesWithUserModel[]
}) { }) {
const formValues = useForm<FormValues>({ const formValues = useForm<FormValues>({
@ -41,13 +39,6 @@ export default function DownloadDataModal({opened, onClose, lists, event, query}
const {showDebug} = useShowDebug() const {showDebug} = useShowDebug()
// Fetch all pages
useEffect(() => {
if (opened && query.hasNextPage && !query.isFetchingNextPage) {
query.fetchNextPage()
}
}, [opened, query])
const RenderFieldCheckboxes = ({schema, formKey}: { const RenderFieldCheckboxes = ({schema, formKey}: {
schema: FormSchema | null, schema: FormSchema | null,
formKey: 'questionSchemaFields' | 'statusSchemaFields' formKey: 'questionSchemaFields' | 'statusSchemaFields'
@ -66,17 +57,8 @@ export default function DownloadDataModal({opened, onClose, lists, event, query}
)) ))
) )
const totalPages = query.data?.pages?.[0].totalPages || 0
const currentPage = query.data?.pages?.length || 0
const progress = (currentPage / totalPages * 100) || 0
const dataIsFetching = query.isFetching || query.isPending
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
// all loaded entries
const entries = query.data?.pages.flatMap(p => p.items) ?? []
const selectedFields = { const selectedFields = {
questionSchemaFields: formValues.values.questionSchemaFields.filter(onlyUnique), questionSchemaFields: formValues.values.questionSchemaFields.filter(onlyUnique),
statusSchemaFields: formValues.values.statusSchemaFields.filter(onlyUnique) statusSchemaFields: formValues.values.statusSchemaFields.filter(onlyUnique)
@ -154,25 +136,15 @@ export default function DownloadDataModal({opened, onClose, lists, event, query}
> >
<div className={"stack"}> <div className={"stack"}>
<ShowHelp> <ShowHelp>
In der CSV sind automatisch die Felder <em>Person</em>, <em>Anmeldezeitpunkt</em>, In der CSV sind automatisch die Felder
<em>Anmelde-Liste</em> und <em>Zeitslot</em> enthalten. Diese <em>Person</em>, <em>Anmeldezeitpunkt</em>,
Felder können nicht abgewählt werden. <em>Anmelde-Liste</em> und <em>Zeitslot</em>
enthalten.
<br/> <br/>
Um Listen spezifische Daten herunterzuladen, wähle im vorherigen Schritt eine oder mehrere Listen aus. Um Listen spezifische Formularfelder herunterzuladen,
wähle im vorherigen Schritt eine oder mehrere Listen aus.
</ShowHelp> </ShowHelp>
{dataIsFetching && <>
<Text c={"dimmed"} size={"xs"}>
Daten werden gesammelt ...
</Text>
<Progress.Root size="xl">
<Progress.Section value={progress} animated>
<Progress.Label>{progress.toFixed(0)}%</Progress.Label>
</Progress.Section>
</Progress.Root>
</>}
<Fieldset legend={event.name}> <Fieldset legend={event.name}>
<div className={"stack"}> <div className={"stack"}>
<Text c={"dimmed"} size={"xs"}> <Text c={"dimmed"} size={"xs"}>
@ -227,8 +199,6 @@ export default function DownloadDataModal({opened, onClose, lists, event, query}
</Button> </Button>
<Button <Button
onClick={() => mutation.mutate()} onClick={() => mutation.mutate()}
disabled={dataIsFetching}
loading={dataIsFetching}
leftSection={<IconDownload/>} leftSection={<IconDownload/>}
> >
CSV herunterladen CSV herunterladen

View File

@ -1,165 +0,0 @@
import {EventListModel, EventListSlotEntriesWithUserModel, EventModel} from "@/models/EventTypes.ts";
import {ListResult} from "pocketbase";
import {InfiniteData, UseInfiniteQueryResult, useMutation} from "@tanstack/react-query";
import {Button, Group, Modal, Progress, Text, Textarea, TextInput, Title} from "@mantine/core";
import {IconSend} from "@tabler/icons-react";
import {hasLength, useForm} from "@mantine/form";
import ShowHelp from "@/components/ShowHelp.tsx";
import TextEditor from "@/components/input/Editor";
import ListSelect from "@/pages/events/e/:eventId/EventLists/ListSelect.tsx";
import {showSuccessNotification} from "@/components/util.tsx";
import {usePB} from "@/lib/pocketbase.tsx";
import {useEffect} from "react";
export default function MessageEntriesModal({opened, event, onClose, query}: {
opened: boolean
onClose: () => void,
event: EventModel,
query: UseInfiniteQueryResult<InfiniteData<ListResult<EventListSlotEntriesWithUserModel>, unknown>, Error>
}) {
const {pb, user} = usePB()
const totalPages = query.data?.pages?.[0].totalPages || 0
const currentPage = query.data?.pages?.length || 0
const progress = (currentPage / totalPages * 100) || 0
const dataIsFetching = query.isFetching || query.isPending
const entries = query.data?.pages.flatMap(p => p.items) ?? []
const formValues = useForm({
initialValues: {
subject: "",
content: "",
comment: "",
selectedLists: [] as EventListModel[],
},
validate: {
subject: hasLength({max: 255}, "Der Betreff ist zu lang (max. 255 Zeichen)"),
content: hasLength({min: 10, max: 10000}, "Die Nachricht muss zwischen 10 und 10000 Zeichen lang sein"),
}
})
// Fetch all pages
useEffect(() => {
if (opened && query.hasNextPage && !query.isFetchingNextPage) {
query.fetchNextPage()
}
}, [opened, query])
const mutation = useMutation({
mutationFn: async () => {
await pb.collection("messages").create({
subject: formValues.values.subject ?? undefined,
content: formValues.values.content,
recipients: entries.map(e => e.user),
isAnnouncement: true,
sender: user?.id,
meta: {
event: event.id
}
})
for (const l of formValues.values.selectedLists) {
await pb.collection("messages").create({
content: formValues.values.content,
comment: formValues.values.comment ?? undefined,
sender: user?.id,
eventList: l.id,
})
}
},
onSuccess: () => {
showSuccessNotification("Nachrichten wurden versendet")
formValues.reset()
onClose()
}
})
return <Modal
opened={opened}
onClose={onClose}
title="Personen benachrichtigen"
size="xl"
>
<form
className={"stack"}
onSubmit={formValues.onSubmit(() => {
mutation.mutate()
})}
>
{dataIsFetching && <>
<Text c={"dimmed"} size={"xs"}>
Daten werden gesammelt ...
</Text>
<Progress.Root size="xl">
<Progress.Section value={progress} animated>
<Progress.Label>{progress.toFixed(0)}%</Progress.Label>
</Progress.Section>
</Progress.Root>
</>}
<ShowHelp>
Mit dieser Funktion kannst du eine Ankündigung an alle Personen senden,
die du in der vorherigen Suche gefunden hast.
<br/>
Die Ankündigung enthält als zusätzliche Information den Namen des Events und
alle Anmeldungen der jeweiligen Person.
<br/>
Wenn du eine Liste (z.B. Orga-Team) darüber informieren möchtest, dass du
die Ankündigung verschickt hast, kannst du diese unter BCC auswählen. Über
den Kommentar kannst du dieser Liste zusätzliche Informationen mitteilen, z.B.
welchem Personenkreis die Nachricht gesendet wurde.
</ShowHelp>
<ListSelect
event={event}
filter={`enableChat=true`}
selectedRecords={formValues.values.selectedLists}
setSelectedRecords={(ls) => formValues.setFieldValue("selectedLists", ls)}
placeholder={"BCC"}
/>
{
formValues.values.selectedLists.length > 0 && <>
<Textarea
placeholder={"Kommentar an die BCC-Listen"}
{...formValues.getInputProps("comment")}
/>
<Title order={3}>
Nachricht
</Title>
</>
}
<TextInput
placeholder={"Betreff"}
{...formValues.getInputProps("subject")}
/>
<TextEditor
placeholder={"Nachricht"}
value={formValues.values.content}
fullToolbar
onChange={content => formValues.setFieldValue("content", content)}
error={formValues.errors.content}
/>
<Group>
<Button onClick={onClose} variant={"light"} color={"orange"}>
Abbrechen
</Button>
<Button
type={"submit"}
disabled={dataIsFetching}
loading={dataIsFetching}
leftSection={<IconSend/>}
>
{entries.length ?? 0} Personen benachrichtigen
</Button>
</Group>
</form>
</Modal>
}

View File

@ -18,7 +18,7 @@ import {
IconFilter, IconFilter,
IconFilterEdit, IconFilterEdit,
IconFilterOff, IconFilterOff,
IconSend, IconMail,
IconUserSearch IconUserSearch
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import {useInfiniteQuery} from "@tanstack/react-query"; import {useInfiniteQuery} from "@tanstack/react-query";
@ -32,20 +32,22 @@ import {FieldEntriesFilter} from "@/components/formUtil/FromInput/types.ts";
import {assembleFilter} from "@/components/formUtil/FormFilter/util.ts"; import {assembleFilter} from "@/components/formUtil/FormFilter/util.ts";
import {DateTimePicker} from "@mantine/dates"; import {DateTimePicker} from "@mantine/dates";
import EventEntries from "@/pages/events/e/:eventId/EventLists/Search/EventEntries.tsx"; import EventEntries from "@/pages/events/e/:eventId/EventLists/Search/EventEntries.tsx";
import {useEventRights} from "@/pages/events/util.ts";
import DownloadDataModal from "@/pages/events/e/:eventId/EventLists/Search/DownloadDataModal.tsx"; import DownloadDataModal from "@/pages/events/e/:eventId/EventLists/Search/DownloadDataModal.tsx";
import MessageEntriesModal from "@/pages/events/e/:eventId/EventLists/Search/MessageEntriesModal.tsx"; import {useMemo, useState} from "react";
import {useMemo} from "react"; import {EmailModal} from "@/components/EmailModal";
import {UserModel} from "@/models/AuthTypes.ts";
import LoadInfinitQueryModal from "@/components/LoadInfinitQueryModal.tsx";
import {getEventMailDefaultText} from "@/pages/events/util.ts";
export default function ListSearch({event}: { event: EventModel }) { export default function ListSearch({event}: { event: EventModel }) {
const {pb} = usePB() const {pb} = usePB()
const {canEditEvent} = useEventRights(event)
const [showFilter, showFilterHandler] = useDisclosure(true) const [showFilter, showFilterHandler] = useDisclosure(true)
const [showDownloadModal, showDownloadModalHandler] = useDisclosure(false) const [showDownloadModal, showDownloadModalHandler] = useDisclosure(false)
const [showMessageModal, showMessageModalHandler] = useDisclosure(false) const [sendEmailModal, sendEmailModalHandler] = useDisclosure(false)
const [action, setAction] = useState<"send" | "download" | null>(null)
const formValues = useForm({ const formValues = useForm({
initialValues: { initialValues: {
@ -183,7 +185,7 @@ export default function ListSearch({event}: { event: EventModel }) {
<Group> <Group>
<FormFilter <FormFilter
label={"Fragen"} label={"Formular"}
schema={questionSchema} schema={questionSchema}
defaultValue={formValues.values.questionFilter} defaultValue={formValues.values.questionFilter}
onChange={(qf) => formValues.setFieldValue("questionFilter", qf)} onChange={(qf) => formValues.setFieldValue("questionFilter", qf)}
@ -225,18 +227,18 @@ export default function ListSearch({event}: { event: EventModel }) {
<Button <Button
size={"xs"} size={"xs"}
leftSection={<IconSend size={16}/>} leftSection={<IconMail size={16}/>}
disabled={!canEditEvent} onClick={() => setAction("send")}
onClick={showMessageModalHandler.toggle} disabled={entries.length === 0}
> >
{entriesCount} Personen benachrichtigen {entriesCount} Personen benachrichtigen
</Button> </Button>
<Button <Button
size={"xs"} size={"xs"}
disabled={!canEditEvent}
leftSection={<IconCsv size={16}/>} leftSection={<IconCsv size={16}/>}
onClick={showDownloadModalHandler.toggle} onClick={() => setAction("download")}
disabled={entries.length === 0}
> >
Daten exportieren Daten exportieren
</Button> </Button>
@ -258,23 +260,40 @@ export default function ListSearch({event}: { event: EventModel }) {
<PocketBaseErrorAlert error={query.error}/> <PocketBaseErrorAlert error={query.error}/>
<EventEntries event={event} entries={entries} refetch={() => query.refetch()}/> <LoadInfinitQueryModal
start={action !== null}
query={query}
onSuccess={() => {
switch (action) {
case "download":
showDownloadModalHandler.open()
break
case "send":
sendEmailModalHandler.open()
break
}
setAction(null)
}}
/>
<DownloadDataModal <DownloadDataModal
event={event} event={event}
opened={showDownloadModal} opened={showDownloadModal}
onClose={showDownloadModalHandler.toggle} onClose={showDownloadModalHandler.toggle}
query={query} entries={entries}
lists={formValues.values.selectedLists} lists={formValues.values.selectedLists}
/> />
<MessageEntriesModal <EmailModal
opened={showMessageModal} opened={sendEmailModal}
onClose={showMessageModalHandler.toggle} onClose={sendEmailModalHandler.close}
query={query} disableUserInput={true}
event={event} recipients={entries.map(e => e.expand?.user).filter(u => u !== undefined && u !== null) as UserModel[]}
content={getEventMailDefaultText(event)}
/> />
<EventEntries event={event} entries={entries} refetch={() => query.refetch()}/>
{query.hasNextPage && ( {query.hasNextPage && (
<Center p={"xs"}> <Center p={"xs"}>
<Button <Button

View File

@ -35,7 +35,7 @@ export default function SharedEvent() {
enabled: !!user enabled: !!user
}) })
const {isPrivilegedUser, canEditEvent} = useEventRights(eventQuery.data) const {canEditEvent} = useEventRights(eventQuery.data)
if (eventQuery.isLoading) { if (eventQuery.isLoading) {
return <Center h={"100%"}><Loader/></Center> return <Center h={"100%"}><Loader/></Center>
@ -108,19 +108,6 @@ export default function SharedEvent() {
</Alert> </Alert>
</div>} </div>}
{isPrivilegedUser && <div className={"section-transparent"}>
<Alert color={"green"} title={"Du kannst dieses Event und alle Teilnehmenden ansehen"}>
<Button
component={"a"}
href={`/events/${eventId}`}
variant={"light"}
leftSection={<IconEye/>}
>
Event ansehen
</Button>
</Alert>
</div>}
{canEditEvent && <div className={"section-transparent"}> {canEditEvent && <div className={"section-transparent"}>
<Alert color={"green"} title={"Du kannst dieses Event bearbeiten"}> <Alert color={"green"} title={"Du kannst dieses Event bearbeiten"}>
<Button <Button

View File

@ -1,13 +1,12 @@
import { import {
EventListSlotEntriesWithUserModel, EventListSlotEntriesWithUserModel,
EventListSlotEntryModel,
EventListSlotModel,
EventListSlotsWithEntriesCountModel, EventListSlotsWithEntriesCountModel,
EventModel EventModel
} from "@/models/EventTypes.ts"; } from "@/models/EventTypes.ts";
import {usePB} from "@/lib/pocketbase.tsx"; import {usePB} from "@/lib/pocketbase.tsx";
import {useSettings} from "@/lib/settings.ts"; import {useSettings} from "@/lib/settings.ts";
import {LdapGroupModel} from "@/models/AuthTypes.ts"; import {LdapGroupModel} from "@/models/AuthTypes.ts";
import {APP_URL} from "../../../config.ts";
export const useEventRights = (event?: EventModel) => { export const useEventRights = (event?: EventModel) => {
@ -22,15 +21,8 @@ export const useEventRights = (event?: EventModel) => {
) )
const isEventAdmin = !!(user && event && event.eventAdmins.includes(user.id)) const isEventAdmin = !!(user && event && event.eventAdmins.includes(user.id))
// for this to work the query of the event must include this backrelation expand:
// 'privilegedLists.eventListSlots_via_eventList.eventListSlotEntries_via_eventListsSlot'
const privilegedUsers = event?.expand?.privilegedLists?.flatMap(pl =>
pl?.expand?.eventListSlots_via_eventList.flatMap((els: EventListSlotModel) =>
els?.expand?.eventListSlotEntries_via_eventListsSlot.flatMap((e: EventListSlotEntryModel) => e.user))) ?? []
return { return {
canEditEvent: isEventAdmin || isStex, canEditEvent: isEventAdmin || isStex,
isPrivilegedUser: user && privilegedUsers.includes(user?.id),
} }
} }
@ -57,3 +49,21 @@ export const getListSchemas = (slot: EventListSlotEntriesWithUserModel | EventLi
} }
} }
} }
/**
* Returns a default text for a mail to send to a user that has signed up for an event.
* @param event The event the user signed up for
*/
export const getEventMailDefaultText = (event: EventModel) => {
return (
`<p>Hallo 👋, </p>` +
`<p>du hast dich für das Event <b><a href="${APP_URL + `/events/s/${event.id}`}">${event.name}</a></b> angemeldet.</p>` +
`<p>Wir haben noch ein paar wichtige Informationen für dich: </p>` +
`<p></p>` +
`<p>...</p>` +
`<p></p>` +
`<p>Du kannst jederzeit alle deine Event-Anmeldungen <a href="${APP_URL + `/events/entries`}">hier</a> einsehen.</p>` +
`<p>Viele Grüße</p>` +
`<p>Dein ${event.name} Team 🎉</p>`
)
}

View File

@ -1,10 +1,8 @@
import {EventCalendar} from "@/pages/events/EventOverview/EventCalendar.tsx"; import {EventCalendar} from "@/pages/events/EventOverview/EventCalendar.tsx";
import {Alert, Button, Group, ThemeIcon, Title} from "@mantine/core"; import {Alert, Button, Group, ThemeIcon, Title} from "@mantine/core";
import {IconConfetti, IconHandLoveYou, IconMessageCircle, IconSpeakerphone, IconUser} from "@tabler/icons-react"; import {IconConfetti, IconHandLoveYou, IconUser} from "@tabler/icons-react";
import {usePB} from "@/lib/pocketbase.tsx"; import {usePB} from "@/lib/pocketbase.tsx";
import {NavLink} from "react-router-dom"; import {NavLink} from "react-router-dom";
import Announcements from "@/pages/chat/components/Announcements.tsx";
import classes from './index.module.css'
import {ReactNode} from "react"; import {ReactNode} from "react";
import {getUserName} from "@/components/users/modals/util.tsx"; import {getUserName} from "@/components/users/modals/util.tsx";
@ -55,21 +53,6 @@ export default function HomePage() {
</>} </>}
</div> </div>
{user && <>
<div className={"section stack"}>
<Title order={2}>Ankündigungen</Title>
<NavButtons buttons={[
{title: "Ankündigungen", icon: <IconSpeakerphone/>, to: "/chat/announcements"},
{title: "Chat", icon: <IconMessageCircle/>, to: "/chat"},
]}/>
<div className={classes.announcementsContainer}>
<Announcements/>
</div>
</div>
</>}
<div className={"section stack"}> <div className={"section stack"}>
<Title order={2}>StuVe Events</Title> <Title order={2}>StuVe Events</Title>

View File

@ -1,5 +1,16 @@
import ShowDebug, {useShowDebug} from "@/components/ShowDebug.tsx"; import ShowDebug, {useShowDebug} from "@/components/ShowDebug.tsx";
import {ActionIcon, Alert, Divider, Group, LoadingOverlay, Pagination, Text, TextInput, Title} from "@mantine/core"; import {
ActionIcon,
Alert,
Button,
Divider,
Group,
LoadingOverlay,
Pagination,
Text,
TextInput,
Title
} from "@mantine/core";
import {useForm} from "@mantine/form"; import {useForm} from "@mantine/form";
import {useQuery} from "@tanstack/react-query"; import {useQuery} from "@tanstack/react-query";
import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx"; import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx";
@ -7,12 +18,19 @@ import {CodeHighlight} from "@mantine/code-highlight";
import {IconRefresh} from "@tabler/icons-react"; import {IconRefresh} from "@tabler/icons-react";
import {RecordListOptions} from "pocketbase"; import {RecordListOptions} from "pocketbase";
import {useState} from "react"; import {useState} from "react";
import {useDisclosure} from "@mantine/hooks";
import {EmailModal} from "@/components/EmailModal";
import {UserModel} from "@/models/AuthTypes.ts";
import UserInput from "@/components/users/UserInput.tsx";
export default function DebugPage() { export default function DebugPage() {
const {showDebug} = useShowDebug() const {showDebug} = useShowDebug()
const [users, setUsers] = useState<UserModel[]>([])
const {pb} = usePB() const {pb} = usePB()
const [opened, handler] = useDisclosure()
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
@ -52,6 +70,24 @@ export default function DebugPage() {
const result = formValues.values.select ? debugQuery.data?.items.map(i => i[formValues.values.select]) : debugQuery.data?.items const result = formValues.values.select ? debugQuery.data?.items.map(i => i[formValues.values.select]) : debugQuery.data?.items
return <> return <>
<UserInput
required
placeholder={"Empfängerinnen"}
variant={"filled"}
selectedRecords={users}
setSelectedRecords={setUsers}
error={formValues.errors.recipients}
/>
<EmailModal
opened={opened}
onClose={handler.close}
recipients={users}
description={"Hier kannst du eine Email an einen oder mehrere Empfänger senden."}
/>
<Button onClick={handler.toggle}>Open Email Modal</Button>
<div className={"section"}> <div className={"section"}>
<Title c={"orange"} order={1}>Debug</Title> <Title c={"orange"} order={1}>Debug</Title>
</div> </div>
@ -64,21 +100,25 @@ export default function DebugPage() {
Über das Select Feld kannst du die Ausgabe auf ein bestimmtes Feld reduzieren, anstatt die gesamten Über das Select Feld kannst du die Ausgabe auf ein bestimmtes Feld reduzieren, anstatt die gesamten
Records zu sehen. Records zu sehen.
</ShowDebug> </ShowDebug>
<TextInput <TextInput
label={"Collection Name"} label={"Collection Name"}
placeholder={"collectionName"} placeholder={"collectionName"}
{...formValues.getInputProps("collectionName")} {...formValues.getInputProps("collectionName")}
/> />
<TextInput <TextInput
label={"Filter"} label={"Filter"}
placeholder={"filter"} placeholder={"filter"}
{...formValues.getInputProps("filter")} {...formValues.getInputProps("filter")}
/> />
<TextInput <TextInput
label={"Sort"} label={"Sort"}
placeholder={"sort"} placeholder={"sort"}
{...formValues.getInputProps("sort")} {...formValues.getInputProps("sort")}
/> />
<TextInput <TextInput
label={"Expand"} label={"Expand"}
placeholder={"expand"} placeholder={"expand"}

396
yarn.lock
View File

@ -209,115 +209,120 @@
"@babel/helper-validator-identifier" "^7.24.5" "@babel/helper-validator-identifier" "^7.24.5"
to-fast-properties "^2.0.0" to-fast-properties "^2.0.0"
"@esbuild/android-arm64@0.18.20": "@esbuild/aix-ppc64@0.19.12":
version "0.18.20" version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz#984b4f9c8d0377443cc2dfcef266d02244593622" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz#d1bc06aedb6936b3b6d313bf809a5a40387d2b7f"
integrity sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ== integrity sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==
"@esbuild/android-arm@0.18.20": "@esbuild/android-arm64@0.19.12":
version "0.18.20" version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.18.20.tgz#fedb265bc3a589c84cc11f810804f234947c3682" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz#7ad65a36cfdb7e0d429c353e00f680d737c2aed4"
integrity sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw== integrity sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==
"@esbuild/android-x64@0.18.20": "@esbuild/android-arm@0.19.12":
version "0.18.20" version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.18.20.tgz#35cf419c4cfc8babe8893d296cd990e9e9f756f2" resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.19.12.tgz#b0c26536f37776162ca8bde25e42040c203f2824"
integrity sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg== integrity sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==
"@esbuild/darwin-arm64@0.18.20": "@esbuild/android-x64@0.19.12":
version "0.18.20" version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz#08172cbeccf95fbc383399a7f39cfbddaeb0d7c1" resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.19.12.tgz#cb13e2211282012194d89bf3bfe7721273473b3d"
integrity sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA== integrity sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==
"@esbuild/darwin-x64@0.18.20": "@esbuild/darwin-arm64@0.19.12":
version "0.18.20" version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz#d70d5790d8bf475556b67d0f8b7c5bdff053d85d" resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz#cbee41e988020d4b516e9d9e44dd29200996275e"
integrity sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ== integrity sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==
"@esbuild/freebsd-arm64@0.18.20": "@esbuild/darwin-x64@0.19.12":
version "0.18.20" version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz#98755cd12707f93f210e2494d6a4b51b96977f54" resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz#e37d9633246d52aecf491ee916ece709f9d5f4cd"
integrity sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw== integrity sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==
"@esbuild/freebsd-x64@0.18.20": "@esbuild/freebsd-arm64@0.19.12":
version "0.18.20" version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz#c1eb2bff03915f87c29cece4c1a7fa1f423b066e" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz#1ee4d8b682ed363b08af74d1ea2b2b4dbba76487"
integrity sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ== integrity sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==
"@esbuild/linux-arm64@0.18.20": "@esbuild/freebsd-x64@0.19.12":
version "0.18.20" version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz#bad4238bd8f4fc25b5a021280c770ab5fc3a02a0" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz#37a693553d42ff77cd7126764b535fb6cc28a11c"
integrity sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA== integrity sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==
"@esbuild/linux-arm@0.18.20": "@esbuild/linux-arm64@0.19.12":
version "0.18.20" version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz#3e617c61f33508a27150ee417543c8ab5acc73b0" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz#be9b145985ec6c57470e0e051d887b09dddb2d4b"
integrity sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg== integrity sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==
"@esbuild/linux-ia32@0.18.20": "@esbuild/linux-arm@0.19.12":
version "0.18.20" version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz#699391cccba9aee6019b7f9892eb99219f1570a7" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz#207ecd982a8db95f7b5279207d0ff2331acf5eef"
integrity sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA== integrity sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==
"@esbuild/linux-loong64@0.18.20": "@esbuild/linux-ia32@0.19.12":
version "0.18.20" version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz#e6fccb7aac178dd2ffb9860465ac89d7f23b977d" resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz#d0d86b5ca1562523dc284a6723293a52d5860601"
integrity sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg== integrity sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==
"@esbuild/linux-mips64el@0.18.20": "@esbuild/linux-loong64@0.19.12":
version "0.18.20" version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz#eeff3a937de9c2310de30622a957ad1bd9183231" resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz#9a37f87fec4b8408e682b528391fa22afd952299"
integrity sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ== integrity sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==
"@esbuild/linux-ppc64@0.18.20": "@esbuild/linux-mips64el@0.19.12":
version "0.18.20" version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz#2f7156bde20b01527993e6881435ad79ba9599fb" resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz#4ddebd4e6eeba20b509d8e74c8e30d8ace0b89ec"
integrity sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA== integrity sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==
"@esbuild/linux-riscv64@0.18.20": "@esbuild/linux-ppc64@0.19.12":
version "0.18.20" version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz#6628389f210123d8b4743045af8caa7d4ddfc7a6" resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz#adb67dadb73656849f63cd522f5ecb351dd8dee8"
integrity sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A== integrity sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==
"@esbuild/linux-s390x@0.18.20": "@esbuild/linux-riscv64@0.19.12":
version "0.18.20" version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz#255e81fb289b101026131858ab99fba63dcf0071" resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz#11bc0698bf0a2abf8727f1c7ace2112612c15adf"
integrity sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ== integrity sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==
"@esbuild/linux-x64@0.18.20": "@esbuild/linux-s390x@0.19.12":
version "0.18.20" version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz#c7690b3417af318a9b6f96df3031a8865176d338" resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz#e86fb8ffba7c5c92ba91fc3b27ed5a70196c3cc8"
integrity sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w== integrity sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==
"@esbuild/netbsd-x64@0.18.20": "@esbuild/linux-x64@0.19.12":
version "0.18.20" version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz#30e8cd8a3dded63975e2df2438ca109601ebe0d1" resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz#5f37cfdc705aea687dfe5dfbec086a05acfe9c78"
integrity sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A== integrity sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==
"@esbuild/openbsd-x64@0.18.20": "@esbuild/netbsd-x64@0.19.12":
version "0.18.20" version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz#7812af31b205055874c8082ea9cf9ab0da6217ae" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz#29da566a75324e0d0dd7e47519ba2f7ef168657b"
integrity sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg== integrity sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==
"@esbuild/sunos-x64@0.18.20": "@esbuild/openbsd-x64@0.19.12":
version "0.18.20" version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz#d5c275c3b4e73c9b0ecd38d1ca62c020f887ab9d" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz#306c0acbdb5a99c95be98bdd1d47c916e7dc3ff0"
integrity sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ== integrity sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==
"@esbuild/win32-arm64@0.18.20": "@esbuild/sunos-x64@0.19.12":
version "0.18.20" version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz#73bc7f5a9f8a77805f357fab97f290d0e4820ac9" resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz#0933eaab9af8b9b2c930236f62aae3fc593faf30"
integrity sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg== integrity sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==
"@esbuild/win32-ia32@0.18.20": "@esbuild/win32-arm64@0.19.12":
version "0.18.20" version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz#ec93cbf0ef1085cc12e71e0d661d20569ff42102" resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz#773bdbaa1971b36db2f6560088639ccd1e6773ae"
integrity sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g== integrity sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==
"@esbuild/win32-x64@0.18.20": "@esbuild/win32-ia32@0.19.12":
version "0.18.20" version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz#786c5f41f043b07afb1af37683d7c33668858f6d" resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz#000516cad06354cc84a73f0943a4aa690ef6fd67"
integrity sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ== integrity sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==
"@esbuild/win32-x64@0.19.12":
version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz#c57c8afbb4054a3ab8317591a0b7320360b444ae"
integrity sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==
"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": "@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0":
version "4.4.0" version "4.4.0"
@ -576,6 +581,96 @@
estree-walker "^2.0.2" estree-walker "^2.0.2"
picomatch "^2.3.1" picomatch "^2.3.1"
"@rollup/rollup-android-arm-eabi@4.24.3":
version "4.24.3"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.24.3.tgz#49a2a9808074f2683667992aa94b288e0b54fc82"
integrity sha512-ufb2CH2KfBWPJok95frEZZ82LtDl0A6QKTa8MoM+cWwDZvVGl5/jNb79pIhRvAalUu+7LD91VYR0nwRD799HkQ==
"@rollup/rollup-android-arm64@4.24.3":
version "4.24.3"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.24.3.tgz#197e3bc01c228d3c23591e0fcedca91f8f398ec1"
integrity sha512-iAHpft/eQk9vkWIV5t22V77d90CRofgR2006UiCjHcHJFVI1E0oBkQIAbz+pLtthFw3hWEmVB4ilxGyBf48i2Q==
"@rollup/rollup-darwin-arm64@4.24.3":
version "4.24.3"
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.24.3.tgz#16772c0309d0dc3cca716580cdac7a1c560ddf46"
integrity sha512-QPW2YmkWLlvqmOa2OwrfqLJqkHm7kJCIMq9kOz40Zo9Ipi40kf9ONG5Sz76zszrmIZZ4hgRIkez69YnTHgEz1w==
"@rollup/rollup-darwin-x64@4.24.3":
version "4.24.3"
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.24.3.tgz#4e98120a1c4cda7d4043ccce72347cee53784140"
integrity sha512-KO0pN5x3+uZm1ZXeIfDqwcvnQ9UEGN8JX5ufhmgH5Lz4ujjZMAnxQygZAVGemFWn+ZZC0FQopruV4lqmGMshow==
"@rollup/rollup-freebsd-arm64@4.24.3":
version "4.24.3"
resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.24.3.tgz#27145e414986e216e0d9b9a8d488028f33c39566"
integrity sha512-CsC+ZdIiZCZbBI+aRlWpYJMSWvVssPuWqrDy/zi9YfnatKKSLFCe6fjna1grHuo/nVaHG+kiglpRhyBQYRTK4A==
"@rollup/rollup-freebsd-x64@4.24.3":
version "4.24.3"
resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.24.3.tgz#67e75fd87a903090f038b212273c492e5ca6b32f"
integrity sha512-F0nqiLThcfKvRQhZEzMIXOQG4EeX61im61VYL1jo4eBxv4aZRmpin6crnBJQ/nWnCsjH5F6J3W6Stdm0mBNqBg==
"@rollup/rollup-linux-arm-gnueabihf@4.24.3":
version "4.24.3"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.24.3.tgz#bb45ebadbb9496298ab5461373bde357e8f33e88"
integrity sha512-KRSFHyE/RdxQ1CSeOIBVIAxStFC/hnBgVcaiCkQaVC+EYDtTe4X7z5tBkFyRoBgUGtB6Xg6t9t2kulnX6wJc6A==
"@rollup/rollup-linux-arm-musleabihf@4.24.3":
version "4.24.3"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.24.3.tgz#384276c23feb0a4d6ffa603a9a760decce8b4118"
integrity sha512-h6Q8MT+e05zP5BxEKz0vi0DhthLdrNEnspdLzkoFqGwnmOzakEHSlXfVyA4HJ322QtFy7biUAVFPvIDEDQa6rw==
"@rollup/rollup-linux-arm64-gnu@4.24.3":
version "4.24.3"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.24.3.tgz#89e5a4570ddd9eca908324a6de60bd64f904e3f0"
integrity sha512-fKElSyXhXIJ9pqiYRqisfirIo2Z5pTTve5K438URf08fsypXrEkVmShkSfM8GJ1aUyvjakT+fn2W7Czlpd/0FQ==
"@rollup/rollup-linux-arm64-musl@4.24.3":
version "4.24.3"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.24.3.tgz#9ffd7cd6c6c6670d8c039056d6a49ad9f1f66949"
integrity sha512-YlddZSUk8G0px9/+V9PVilVDC6ydMz7WquxozToozSnfFK6wa6ne1ATUjUvjin09jp34p84milxlY5ikueoenw==
"@rollup/rollup-linux-powerpc64le-gnu@4.24.3":
version "4.24.3"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.24.3.tgz#4d32ce982e2d25e3b8116336ad5ce6e270b5a024"
integrity sha512-yNaWw+GAO8JjVx3s3cMeG5Esz1cKVzz8PkTJSfYzE5u7A+NvGmbVFEHP+BikTIyYWuz0+DX9kaA3pH9Sqxp69g==
"@rollup/rollup-linux-riscv64-gnu@4.24.3":
version "4.24.3"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.24.3.tgz#f43d4e0572397e3d3acd82d77d79ce021dea3310"
integrity sha512-lWKNQfsbpv14ZCtM/HkjCTm4oWTKTfxPmr7iPfp3AHSqyoTz5AgLemYkWLwOBWc+XxBbrU9SCokZP0WlBZM9lA==
"@rollup/rollup-linux-s390x-gnu@4.24.3":
version "4.24.3"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.24.3.tgz#264f8a4c206173945bdab2a676d638b7945106a9"
integrity sha512-HoojGXTC2CgCcq0Woc/dn12wQUlkNyfH0I1ABK4Ni9YXyFQa86Fkt2Q0nqgLfbhkyfQ6003i3qQk9pLh/SpAYw==
"@rollup/rollup-linux-x64-gnu@4.24.3":
version "4.24.3"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.24.3.tgz#e86172a407b2edd41540ec2ae636e497fadccff6"
integrity sha512-mnEOh4iE4USSccBOtcrjF5nj+5/zm6NcNhbSEfR3Ot0pxBwvEn5QVUXcuOwwPkapDtGZ6pT02xLoPaNv06w7KQ==
"@rollup/rollup-linux-x64-musl@4.24.3":
version "4.24.3"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.24.3.tgz#8ae9bf78986d1b16ccbc89ab6f2dfa96807d3178"
integrity sha512-rMTzawBPimBQkG9NKpNHvquIUTQPzrnPxPbCY1Xt+mFkW7pshvyIS5kYgcf74goxXOQk0CP3EoOC1zcEezKXhw==
"@rollup/rollup-win32-arm64-msvc@4.24.3":
version "4.24.3"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.24.3.tgz#11d6a59f651a3c2a9e5eaab0a99367b77a29c319"
integrity sha512-2lg1CE305xNvnH3SyiKwPVsTVLCg4TmNCF1z7PSHX2uZY2VbUpdkgAllVoISD7JO7zu+YynpWNSKAtOrX3AiuA==
"@rollup/rollup-win32-ia32-msvc@4.24.3":
version "4.24.3"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.24.3.tgz#7ff146e53dc6e388b60329b7ec3335501d2b0f98"
integrity sha512-9SjYp1sPyxJsPWuhOCX6F4jUMXGbVVd5obVpoVEi8ClZqo52ViZewA6eFz85y8ezuOA+uJMP5A5zo6Oz4S5rVQ==
"@rollup/rollup-win32-x64-msvc@4.24.3":
version "4.24.3"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.24.3.tgz#7687335781efe6bee14d6ed8eff9746a9f24c9cd"
integrity sha512-HGZgRFFYrMrP3TJlq58nR1xy8zHKId25vhmm5S9jETEfDf6xybPxsavFTJaufe2zgOGYJBskGlj49CwtEuFhWQ==
"@svgr/babel-plugin-add-jsx-attribute@8.0.0": "@svgr/babel-plugin-add-jsx-attribute@8.0.0":
version "8.0.0" version "8.0.0"
resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz#4001f5d5dd87fa13303e36ee106e3ff3a7eb8b22" resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz#4001f5d5dd87fa13303e36ee106e3ff3a7eb8b22"
@ -1011,6 +1106,11 @@
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4"
integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==
"@types/estree@1.0.6":
version "1.0.6"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50"
integrity sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==
"@types/file-saver@^2.0.7": "@types/file-saver@^2.0.7":
version "2.0.7" version "2.0.7"
resolved "https://registry.yarnpkg.com/@types/file-saver/-/file-saver-2.0.7.tgz#8dbb2f24bdc7486c54aa854eb414940bbd056f7d" resolved "https://registry.yarnpkg.com/@types/file-saver/-/file-saver-2.0.7.tgz#8dbb2f24bdc7486c54aa854eb414940bbd056f7d"
@ -1664,33 +1764,34 @@ error-ex@^1.3.1:
dependencies: dependencies:
is-arrayish "^0.2.1" is-arrayish "^0.2.1"
esbuild@^0.18.10: esbuild@^0.19.3:
version "0.18.20" version "0.19.12"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.18.20.tgz#4709f5a34801b43b799ab7d6d82f7284a9b7a7a6" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.19.12.tgz#dc82ee5dc79e82f5a5c3b4323a2a641827db3e04"
integrity sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA== integrity sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==
optionalDependencies: optionalDependencies:
"@esbuild/android-arm" "0.18.20" "@esbuild/aix-ppc64" "0.19.12"
"@esbuild/android-arm64" "0.18.20" "@esbuild/android-arm" "0.19.12"
"@esbuild/android-x64" "0.18.20" "@esbuild/android-arm64" "0.19.12"
"@esbuild/darwin-arm64" "0.18.20" "@esbuild/android-x64" "0.19.12"
"@esbuild/darwin-x64" "0.18.20" "@esbuild/darwin-arm64" "0.19.12"
"@esbuild/freebsd-arm64" "0.18.20" "@esbuild/darwin-x64" "0.19.12"
"@esbuild/freebsd-x64" "0.18.20" "@esbuild/freebsd-arm64" "0.19.12"
"@esbuild/linux-arm" "0.18.20" "@esbuild/freebsd-x64" "0.19.12"
"@esbuild/linux-arm64" "0.18.20" "@esbuild/linux-arm" "0.19.12"
"@esbuild/linux-ia32" "0.18.20" "@esbuild/linux-arm64" "0.19.12"
"@esbuild/linux-loong64" "0.18.20" "@esbuild/linux-ia32" "0.19.12"
"@esbuild/linux-mips64el" "0.18.20" "@esbuild/linux-loong64" "0.19.12"
"@esbuild/linux-ppc64" "0.18.20" "@esbuild/linux-mips64el" "0.19.12"
"@esbuild/linux-riscv64" "0.18.20" "@esbuild/linux-ppc64" "0.19.12"
"@esbuild/linux-s390x" "0.18.20" "@esbuild/linux-riscv64" "0.19.12"
"@esbuild/linux-x64" "0.18.20" "@esbuild/linux-s390x" "0.19.12"
"@esbuild/netbsd-x64" "0.18.20" "@esbuild/linux-x64" "0.19.12"
"@esbuild/openbsd-x64" "0.18.20" "@esbuild/netbsd-x64" "0.19.12"
"@esbuild/sunos-x64" "0.18.20" "@esbuild/openbsd-x64" "0.19.12"
"@esbuild/win32-arm64" "0.18.20" "@esbuild/sunos-x64" "0.19.12"
"@esbuild/win32-ia32" "0.18.20" "@esbuild/win32-arm64" "0.19.12"
"@esbuild/win32-x64" "0.18.20" "@esbuild/win32-ia32" "0.19.12"
"@esbuild/win32-x64" "0.19.12"
escalade@^3.1.2: escalade@^3.1.2:
version "3.1.2" version "3.1.2"
@ -1913,7 +2014,7 @@ fs.realpath@^1.0.0:
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
fsevents@~2.3.2: fsevents@~2.3.2, fsevents@~2.3.3:
version "2.3.3" version "2.3.3"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
@ -2991,6 +3092,11 @@ picocolors@^1.0.0:
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c"
integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==
picocolors@^1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1:
version "2.3.1" version "2.3.1"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
@ -3055,7 +3161,7 @@ postcss@^8.3.11:
picocolors "^1.0.0" picocolors "^1.0.0"
source-map-js "^1.2.0" source-map-js "^1.2.0"
postcss@^8.4.27, postcss@^8.4.31: postcss@^8.4.31:
version "8.4.31" version "8.4.31"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d"
integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ== integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==
@ -3064,6 +3170,15 @@ postcss@^8.4.27, postcss@^8.4.31:
picocolors "^1.0.0" picocolors "^1.0.0"
source-map-js "^1.0.2" source-map-js "^1.0.2"
postcss@^8.4.33:
version "8.4.47"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.47.tgz#5bf6c9a010f3e724c503bf03ef7947dcb0fea365"
integrity sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==
dependencies:
nanoid "^3.3.7"
picocolors "^1.1.0"
source-map-js "^1.2.1"
prelude-ls@^1.2.1: prelude-ls@^1.2.1:
version "1.2.1" version "1.2.1"
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
@ -3525,11 +3640,31 @@ rimraf@^3.0.2:
dependencies: dependencies:
glob "^7.1.3" glob "^7.1.3"
rollup@^3.27.1: rollup@^4.2.0:
version "3.29.4" version "4.24.3"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.29.4.tgz#4d70c0f9834146df8705bfb69a9a19c9e1109981" resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.24.3.tgz#8b259063740af60b0030315f88665ba2041789b8"
integrity sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw== integrity sha512-HBW896xR5HGmoksbi3JBDtmVzWiPAYqp7wip50hjQ67JbDz61nyoMPdqu1DvVW9asYb2M65Z20ZHsyJCMqMyDg==
dependencies:
"@types/estree" "1.0.6"
optionalDependencies: optionalDependencies:
"@rollup/rollup-android-arm-eabi" "4.24.3"
"@rollup/rollup-android-arm64" "4.24.3"
"@rollup/rollup-darwin-arm64" "4.24.3"
"@rollup/rollup-darwin-x64" "4.24.3"
"@rollup/rollup-freebsd-arm64" "4.24.3"
"@rollup/rollup-freebsd-x64" "4.24.3"
"@rollup/rollup-linux-arm-gnueabihf" "4.24.3"
"@rollup/rollup-linux-arm-musleabihf" "4.24.3"
"@rollup/rollup-linux-arm64-gnu" "4.24.3"
"@rollup/rollup-linux-arm64-musl" "4.24.3"
"@rollup/rollup-linux-powerpc64le-gnu" "4.24.3"
"@rollup/rollup-linux-riscv64-gnu" "4.24.3"
"@rollup/rollup-linux-s390x-gnu" "4.24.3"
"@rollup/rollup-linux-x64-gnu" "4.24.3"
"@rollup/rollup-linux-x64-musl" "4.24.3"
"@rollup/rollup-win32-arm64-msvc" "4.24.3"
"@rollup/rollup-win32-ia32-msvc" "4.24.3"
"@rollup/rollup-win32-x64-msvc" "4.24.3"
fsevents "~2.3.2" fsevents "~2.3.2"
rope-sequence@^1.3.0: rope-sequence@^1.3.0:
@ -3624,6 +3759,11 @@ source-map-js@^1.0.2:
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
source-map-js@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
space-separated-tokens@^2.0.0: space-separated-tokens@^2.0.0:
version "2.0.2" version "2.0.2"
resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz#1ecd9d2350a3844572c3f4a312bceb018348859f" resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz#1ecd9d2350a3844572c3f4a312bceb018348859f"
@ -3966,16 +4106,16 @@ vite-plugin-svgr@^4.2.0:
"@svgr/core" "^8.1.0" "@svgr/core" "^8.1.0"
"@svgr/plugin-jsx" "^8.1.0" "@svgr/plugin-jsx" "^8.1.0"
vite@^4.4.5: vite@5.1.0-beta.2:
version "4.5.0" version "5.1.0-beta.2"
resolved "https://registry.yarnpkg.com/vite/-/vite-4.5.0.tgz#ec406295b4167ac3bc23e26f9c8ff559287cff26" resolved "https://registry.yarnpkg.com/vite/-/vite-5.1.0-beta.2.tgz#ca2bf7504952f8e0384ed5d2ed2e13c0ab4fbdd8"
integrity sha512-ulr8rNLA6rkyFAlVWw2q5YJ91v098AFQ2R0PRFwPzREXOUJQPtFUG0t+/ZikhaOCDqFoDhN6/v8Sq0o4araFAw== integrity sha512-FpzQ6WBc2x7F71QmLEP6nCaFy6IlhkfrzYuLEd4Ax8mGju+BncnggAe3e4j6wLnh8FA7GkCxJu1ds2ZY0+Ws4A==
dependencies: dependencies:
esbuild "^0.18.10" esbuild "^0.19.3"
postcss "^8.4.27" postcss "^8.4.33"
rollup "^3.27.1" rollup "^4.2.0"
optionalDependencies: optionalDependencies:
fsevents "~2.3.2" fsevents "~2.3.3"
w3c-keyname@^2.2.0: w3c-keyname@^2.2.0:
version "2.2.8" version "2.2.8"