From fc1103b61a9a257fb1fd918cc0f3b8a6b0680021 Mon Sep 17 00:00:00 2001 From: valentinkolb Date: Thu, 31 Oct 2024 18:00:48 +0100 Subject: [PATCH] feat(app): added email send support removed the in-app chat and added email send support in its place --- src/Router.tsx | 6 +- src/components/EmailModal/index.module.css | 25 ++ src/components/EmailModal/index.tsx | 244 +++++++++++ src/components/LoadInfinitQueryModal.tsx | 71 ++++ src/components/formUtil/FormFilter/index.tsx | 7 +- src/components/input/Editor/index.tsx | 9 +- src/components/input/RecordSearchInput.tsx | 85 ++-- src/components/layout/nav/MenuItems.tsx | 19 +- src/components/layout/nav/index.tsx | 2 - src/components/users/UserInput.tsx | 6 +- src/components/users/UsersDisplay.tsx | 7 +- src/components/users/modals/UserMenuModal.tsx | 17 +- src/components/users/modals/util.tsx | 6 +- src/lib/datetime.ts | 10 + src/lib/pocketbase.tsx | 4 +- src/models/AuthTypes.ts | 3 +- src/models/EmailTypes.ts | 17 + src/models/EventTypes.ts | 11 +- src/models/MessageTypes.ts | 6 +- src/models/index.ts | 7 +- src/pages/chat/EventListMessagesList.tsx | 127 ------ src/pages/chat/ListMessagesView.module.css | 5 - src/pages/chat/ListMessagesView.tsx | 92 ---- .../chat/components/Announcement.module.css | 31 -- src/pages/chat/components/Announcement.tsx | 85 ---- .../chat/components/Announcements.module.css | 7 - src/pages/chat/components/Announcements.tsx | 67 --- src/pages/chat/components/ChatNavIcon.tsx | 96 ----- src/pages/chat/components/Messages.module.css | 53 --- src/pages/chat/components/Messages.tsx | 154 ------- .../chat/components/SendAnnouncements.tsx | 67 --- .../EmailRouter.module.css} | 0 .../ChatRouter.tsx => email/EmailRouter.tsx} | 28 +- src/pages/email/EmailView/index.module.css | 7 + src/pages/email/EmailView/index.tsx | 160 +++++++ .../SentEmailsNavigation.module.css} | 37 +- src/pages/email/SentEmailsNavigation.tsx | 118 ++++++ src/pages/events/EventNavigate.tsx | 8 +- .../events/EventOverview/CreateEvent.tsx | 8 +- src/pages/events/EventOverview/EventList.tsx | 32 +- .../events/StatusEditor/StatusEditor.tsx | 6 +- .../events/e/:eventId/EditEventRouter.tsx | 6 +- .../e/:eventId/EventComponents/EventData.tsx | 19 +- .../EventSettings/EditEventMembers.tsx | 29 +- .../EventLists/:listId/EventListRouter.tsx | 9 - .../ListEntryQuestionSettings.tsx | 4 +- .../:listId/ListSettings/ListSettings.tsx | 14 - .../EventListComponents/EditSlotEntryMenu.tsx | 18 +- .../EditDefaultEntryQuestionSchema.tsx | 2 +- .../EventLists/Search/DownloadDataModal.tsx | 50 +-- .../EventLists/Search/MessageEntriesModal.tsx | 165 -------- .../e/:eventId/EventLists/Search/index.tsx | 59 ++- src/pages/events/s/EventView.tsx | 15 +- src/pages/events/util.ts | 28 +- src/pages/home/index.page.tsx | 19 +- src/pages/test/DebugPage.tsx | 42 +- yarn.lock | 396 ++++++++++++------ 57 files changed, 1215 insertions(+), 1410 deletions(-) create mode 100644 src/components/EmailModal/index.module.css create mode 100644 src/components/EmailModal/index.tsx create mode 100644 src/components/LoadInfinitQueryModal.tsx create mode 100644 src/models/EmailTypes.ts delete mode 100644 src/pages/chat/EventListMessagesList.tsx delete mode 100644 src/pages/chat/ListMessagesView.module.css delete mode 100644 src/pages/chat/ListMessagesView.tsx delete mode 100644 src/pages/chat/components/Announcement.module.css delete mode 100644 src/pages/chat/components/Announcement.tsx delete mode 100644 src/pages/chat/components/Announcements.module.css delete mode 100644 src/pages/chat/components/Announcements.tsx delete mode 100644 src/pages/chat/components/ChatNavIcon.tsx delete mode 100644 src/pages/chat/components/Messages.module.css delete mode 100644 src/pages/chat/components/Messages.tsx delete mode 100644 src/pages/chat/components/SendAnnouncements.tsx rename src/pages/{chat/ChatRouter.module.css => email/EmailRouter.module.css} (100%) rename src/pages/{chat/ChatRouter.tsx => email/EmailRouter.tsx} (54%) create mode 100644 src/pages/email/EmailView/index.module.css create mode 100644 src/pages/email/EmailView/index.tsx rename src/pages/{chat/EventListMessagesList.module.css => email/SentEmailsNavigation.module.css} (68%) create mode 100644 src/pages/email/SentEmailsNavigation.tsx delete mode 100644 src/pages/events/e/:eventId/EventLists/Search/MessageEntriesModal.tsx diff --git a/src/Router.tsx b/src/Router.tsx index 99ec223..50c3281 100644 --- a/src/Router.tsx +++ b/src/Router.tsx @@ -5,8 +5,8 @@ import Layout from "@/components/layout"; import QRCodeGenerator from "./pages/util/qr/index.page.tsx"; import EventsRouter from "./pages/events/EventsRouter.tsx"; import LegalPage from "@/pages/LegalPage.tsx"; -import ChatRouter from "@/pages/chat/ChatRouter.tsx"; import DebugPage from "@/pages/test/DebugPage.tsx"; +import EmailRouter from "@/pages/email/EmailRouter.tsx"; const router = createBrowserRouter([ { @@ -26,8 +26,8 @@ const router = createBrowserRouter([ element: , }, { - path: "chat/*", - element: , + path: "email/*", + element: , }, { path: "debug", diff --git a/src/components/EmailModal/index.module.css b/src/components/EmailModal/index.module.css new file mode 100644 index 0000000..3f4791e --- /dev/null +++ b/src/components/EmailModal/index.module.css @@ -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; +} \ No newline at end of file diff --git a/src/components/EmailModal/index.tsx b/src/components/EmailModal/index.tsx new file mode 100644 index 0000000..94fce31 --- /dev/null +++ b/src/components/EmailModal/index.tsx @@ -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 <> + + }> + Bitte logge dich ein, um eine Email zu senden. + + + + + return <> + +
+
+ + + + + { + if (formValues.validate().hasErrors) return + sendTestEmailMutation.mutate() + }} + color={"gray"} + variant={"subtle"} + title={"Send test email"} + > + { + testEmailWasSent ? + : + + } + + + + { + fullscreenHandler.toggle() + }} + color={"gray"} + variant={"subtle"} + title={"Fullscreen"} + > + {fullscreen ? : } + + + { + props.onClose() + onCancel?.() + }} + color={"gray"} + variant={"subtle"} + title={"Close"} + > + + + +
+ +
+ + + + + + Die in deinem Account gespeicherte E-Mail-Adresse wird in der gesendeten Nachricht + angezeigt, damit die Empfängerin weiß, von wem die Nachricht stammt. + + + formValues.setFieldValue("recipients", val)} + error={formValues.errors.recipients} + /> + + } + variant={"filled"} + placeholder={"Betreff"} + required + {...formValues.getInputProps("subject")} + /> + + formValues.setFieldValue("content", value)} + error={formValues.errors.content} + /> +
+
+
+ +} \ No newline at end of file diff --git a/src/components/LoadInfinitQueryModal.tsx b/src/components/LoadInfinitQueryModal.tsx new file mode 100644 index 0000000..3151ca1 --- /dev/null +++ b/src/components/LoadInfinitQueryModal.tsx @@ -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, Error>} props.query - The infinite query result object. + * + * @returns The modal component. + */ +export default function LoadInfinitQueryModal({start, query, onSuccess, onError}: { + start: boolean + onSuccess?: () => void + onError?: (error: Error) => void + query: UseInfiniteQueryResult, 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 null} + size="xs" + centered + withCloseButton={false} + > +
+ {dataIsFetching && <> + + Daten werden gesammelt ... {fetchedItems}/{totalItems} Einträge + + + + + ~{progress.toFixed(0)}% + + + } +
+
+} diff --git a/src/components/formUtil/FormFilter/index.tsx b/src/components/formUtil/FormFilter/index.tsx index 375056d..4e3b266 100644 --- a/src/components/formUtil/FormFilter/index.tsx +++ b/src/components/formUtil/FormFilter/index.tsx @@ -99,14 +99,17 @@ export default function FormFilter({schema, label, defaultValue, onChange}: { } }) formValues.setValues(newValues) - console.log("Schema changed", values, newValues) // eslint-disable-next-line }, [schema]) return <> - diff --git a/src/components/input/Editor/index.tsx b/src/components/input/Editor/index.tsx index 5276fcd..62190f0 100644 --- a/src/components/input/Editor/index.tsx +++ b/src/components/input/Editor/index.tsx @@ -63,6 +63,8 @@ const Toolbar = ({fullToolbar}: { fullToolbar: boolean, editor: Editor }) => ( + + @@ -79,6 +81,9 @@ const Toolbar = ({fullToolbar}: { fullToolbar: boolean, editor: Editor }) => ( * @param placeholder The placeholder text to show when the editor is empty. * @param fullToolbar Whether to show the full toolbar or not. * @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 noBorder shows no border if true * @param props The props to pass to the Mantine Input Wrapper component. @@ -89,6 +94,7 @@ export default function TextEditor({ placeholder, fullToolbar, maxHeight, + minHeight, hideToolbar, noBorder, disabled, @@ -100,6 +106,7 @@ export default function TextEditor({ placeholder?: string; fullToolbar?: boolean; maxHeight?: number | string; + minHeight?: number | string; hideToolbar?: boolean; noBorder?: boolean; disabled?: boolean; @@ -161,7 +168,7 @@ export default function TextEditor({ toolbar: classes.toolbar, }} > - + {hideToolbar ? : } diff --git a/src/components/input/RecordSearchInput.tsx b/src/components/input/RecordSearchInput.tsx index efa509c..f2b2fea 100644 --- a/src/components/input/RecordSearchInput.tsx +++ b/src/components/input/RecordSearchInput.tsx @@ -1,6 +1,17 @@ import {RecordModel} from "pocketbase"; 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"; /* @@ -17,7 +28,7 @@ export type GenericRecordSearchInputProps = { selectedRecords: T[] setSelectedRecords: (records: T[]) => void placeholder?: string -} & Pick +} & Pick /** * 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(props: { leftSection={props.leftSection} required={props.required} error={props.error} + variant={props.variant} + disabled={props.disabled} > - - { - props.selectedRecords.map((selectedRecord) => ( - handleValueRemove(selectedRecord.id)} - > - {props.recordToString(selectedRecord).displayName} - - )) - } + + + { + props.selectedRecords.map((selectedRecord, index) => ( + handleValueRemove(selectedRecord.id)} + > + {props.recordToString(selectedRecord).displayName} + + )) + } - - combobox.openDropdown()} - onBlur={() => combobox.closeDropdown()} - value={search} - placeholder={props.placeholder} - onChange={(event) => { - combobox.updateSelectedOptionIndex() - setSearch(event.currentTarget.value) - props.recordSearchMutation.mutate(event.currentTarget.value) - }} - onKeyDown={(event) => { - if (event.key === 'Backspace' && search.length === 0) { - event.preventDefault(); - handleValueRemove(props.selectedRecords[props.selectedRecords.length - 1].id) - } - }} - /> - - + + combobox.openDropdown()} + onBlur={() => combobox.closeDropdown()} + value={search} + placeholder={props.placeholder} + onChange={(event) => { + combobox.updateSelectedOptionIndex() + setSearch(event.currentTarget.value) + props.recordSearchMutation.mutate(event.currentTarget.value) + }} + onKeyDown={(event) => { + if (event.key === 'Backspace' && search.length === 0) { + event.preventDefault(); + handleValueRemove(props.selectedRecords[props.selectedRecords.length - 1].id) + } + }} + /> + + + diff --git a/src/components/layout/nav/MenuItems.tsx b/src/components/layout/nav/MenuItems.tsx index a42d935..be9af5c 100644 --- a/src/components/layout/nav/MenuItems.tsx +++ b/src/components/layout/nav/MenuItems.tsx @@ -6,10 +6,9 @@ import { IconConfetti, IconHome, IconList, - IconMessageCircle, + IconMailShare, IconQrcode, - IconSectionSign, - IconSpeakerphone + IconSectionSign } from "@tabler/icons-react"; import {useShowDebug} from "@/components/ShowDebug.tsx"; @@ -25,16 +24,10 @@ const NavItems = [ link: "/" }, { - title: "Nachrichten", - icon: IconMessageCircle, - description: "Nachrichten", - link: "/chat" - }, - { - title: "Ankündigungen", - icon: IconSpeakerphone, - description: "Ankündigungen", - link: "/chat/announcements" + title: "Emails", + icon: IconMailShare, + description: "Emails", + link: "/email" } ] }, diff --git a/src/components/layout/nav/index.tsx b/src/components/layout/nav/index.tsx index f2706a1..096b03d 100644 --- a/src/components/layout/nav/index.tsx +++ b/src/components/layout/nav/index.tsx @@ -4,7 +4,6 @@ import {ActionIcon, Image, Menu, ThemeIcon} from "@mantine/core"; import {IconChevronDown, IconLogin, IconUserStar} from "@tabler/icons-react"; import MenuItems from "./MenuItems.tsx"; import {useLogin, useUserMenu} from "@/components/users/modals/hooks.ts"; -import ChatNavIcon from "@/pages/chat/components/ChatNavIcon.tsx"; export default function NavBar() { @@ -47,7 +46,6 @@ export default function NavBar() {
{user ? <> - ) { +export default function UserInput(props: GenericRecordSearchInputProps) { const {pb} = usePB() return ( + recordToString={(user) => ({displayName: `${user.givenName} ${user.sn}`})} {...props} placeholder={props.placeholder || "Suche nach Personen..."} diff --git a/src/components/users/UsersDisplay.tsx b/src/components/users/UsersDisplay.tsx index 0a2db97..fc11514 100644 --- a/src/components/users/UsersDisplay.tsx +++ b/src/components/users/UsersDisplay.tsx @@ -1,9 +1,10 @@ -import {UserModal} from "@/models/AuthTypes.ts"; +import {UserModel} from "@/models/AuthTypes.ts"; import {List} from "@mantine/core"; import {IconUser} from "@tabler/icons-react"; 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() @@ -12,7 +13,7 @@ export default function UsersDisplay({users}: { users: UserModal[] }) { { users.map((u) => ( - {u.username} + {getUserName(u)} )) } diff --git a/src/components/users/modals/UserMenuModal.tsx b/src/components/users/modals/UserMenuModal.tsx index 06552cb..d002a35 100644 --- a/src/components/users/modals/UserMenuModal.tsx +++ b/src/components/users/modals/UserMenuModal.tsx @@ -34,7 +34,6 @@ import {getUserName} from "@/components/users/modals/util.tsx"; import {isNotEmpty, useForm} from "@mantine/form"; import {useMutation} from "@tanstack/react-query"; import {showSuccessNotification} from "@/components/util.tsx"; -import {CheckboxCard} from "@/components/input/CheckboxCard"; import {useEffect} from "react"; export default function UserMenuModal() { @@ -62,7 +61,6 @@ export default function UserMenuModal() { const values = { sn: user?.sn ?? "", givenName: user?.givenName ?? "", - muteEmailNotifications: user?.muteEmailNotifications ?? false } formValues.setInitialValues(values) formValues.setValues(values) @@ -197,15 +195,16 @@ export default function UserMenuModal() { sondern zeigt zusätzliche Informationen an. - {userHasNoName && Bitte vervollständige deinen Account, um alle Funktionen nutzen zu können. } { user?.REALM === "GUEST" && <> + + } - - { +export const getUserName = (user?: UserModel | null) => { if (!user) { return null } @@ -24,7 +24,7 @@ export const getUserName = (user?: UserModal | null) => { * @param user * @constructor */ -export const RenderUserName = ({user}: { user?: UserModal | null }) => { +export const RenderUserName = ({user}: { user?: UserModel | null }) => { if (!user) { return null diff --git a/src/lib/datetime.ts b/src/lib/datetime.ts index dd8e126..95e0eab 100644 --- a/src/lib/datetime.ts +++ b/src/lib/datetime.ts @@ -31,6 +31,16 @@ export const pprintDate = (date: string | Date | Dayjs): string => { 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". * Uses Dayjs diff --git a/src/lib/pocketbase.tsx b/src/lib/pocketbase.tsx index a4dc475..5fc724e 100644 --- a/src/lib/pocketbase.tsx +++ b/src/lib/pocketbase.tsx @@ -10,7 +10,7 @@ import ms from "ms"; import {useQuery} from "@tanstack/react-query"; import {TypedPocketBase} from "@/models"; 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 {IconAlertTriangle} from "@tabler/icons-react"; import {showSuccessNotification} from "@/components/util.tsx"; @@ -182,7 +182,7 @@ const PocketData = () => { ldapLogin, guestLogin, logout, - user: pb.authStore.isValid ? user as UserModal | null : null, + user: pb.authStore.isValid ? user as UserModel | null : null, pb, refreshUser: refreshUserQuery.refetch, useSubscription, diff --git a/src/models/AuthTypes.ts b/src/models/AuthTypes.ts index a900a9d..cef938e 100644 --- a/src/models/AuthTypes.ts +++ b/src/models/AuthTypes.ts @@ -1,11 +1,10 @@ import {RecordModel} from "pocketbase"; -export type UserModal = { +export type UserModel = { username: string; verified: boolean; email: string; emailVisibility: boolean; - muteEmailNotifications: boolean; sn: string | null; givenName: string | null; diff --git a/src/models/EmailTypes.ts b/src/models/EmailTypes.ts new file mode 100644 index 0000000..2c32766 --- /dev/null +++ b/src/models/EmailTypes.ts @@ -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 \ No newline at end of file diff --git a/src/models/EventTypes.ts b/src/models/EventTypes.ts index 186d460..496cb6c 100644 --- a/src/models/EventTypes.ts +++ b/src/models/EventTypes.ts @@ -1,4 +1,4 @@ -import {UserModal} from "./AuthTypes.ts"; +import {UserModel} from "./AuthTypes.ts"; import {RecordModel} from "pocketbase"; import {FieldEntries} from "@/components/formUtil/FromInput/types.ts"; import {FormSchema} from "@/components/formUtil/formBuilder/types.ts"; @@ -17,10 +17,8 @@ export type EventModel = { eventLinks: EventLink[]; defaultEntryQuestionSchema: FormSchema | null; defaultEntryStatusSchema: FormSchema | null; - privilegedLists: string[]; expand?: { - eventAdmins: UserModal[] | null; - privilegedLists: EventListModel[] | null; + eventAdmins: UserModel[] | null; } } & RecordModel @@ -47,7 +45,6 @@ export type EventListModel = { } } & RecordModel - export type EventListSlotModel = { eventList: string; startDate: string; @@ -71,7 +68,7 @@ export type EventListSlotEntryModel = { user: string | null expand?: { eventListsSlot: EventListSlotModel; - user: UserModal | null; + user: UserModel | null; } } & RecordModel @@ -90,7 +87,7 @@ export type EventListSlotEntriesWithUserModel = expand?: { event: EventModel; eventList: EventListModel; - user: UserModal; + user: UserModel; } } & Pick diff --git a/src/models/MessageTypes.ts b/src/models/MessageTypes.ts index c108832..1ca403c 100644 --- a/src/models/MessageTypes.ts +++ b/src/models/MessageTypes.ts @@ -1,5 +1,5 @@ import {RecordModel} from "pocketbase"; -import {UserModal} from "@/models/AuthTypes.ts"; +import {UserModel} from "@/models/AuthTypes.ts"; import {EventListModel} from "@/models/EventTypes.ts"; export type MessagesModel = { @@ -20,8 +20,8 @@ export type MessagesModel = { } | null expand: { - sender: UserModal - recipients: UserModal[] + sender: UserModel + recipients: UserModel[] eventList: EventListModel | null repliedTo: MessagesModel | null } diff --git a/src/models/index.ts b/src/models/index.ts index cf3a2e9..5a29d0d 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -1,5 +1,5 @@ import PocketBase, {RecordModel, RecordService} from "pocketbase"; -import {LdapGroupModel, LdapSyncLogModel, UserModal} from "./AuthTypes.ts"; +import {LdapGroupModel, LdapSyncLogModel, UserModel} from "./AuthTypes.ts"; import { EventListModel, EventListSlotEntriesWithUserModel, @@ -9,6 +9,7 @@ import { EventModel } from "./EventTypes.ts"; import {MessagesModel} from "@/models/MessageTypes.ts"; +import {EmailModel} from "@/models/EmailTypes.ts"; export type SettingsModel = { key: ['privacyPolicy', 'agb', 'stexGroup', 'stuveEventQuestions'] @@ -33,7 +34,7 @@ export interface TypedPocketBase extends PocketBase { collection(idOrName: 'legalSettings'): RecordService - collection(idOrName: 'users'): RecordService + collection(idOrName: 'users'): RecordService collection(idOrName: 'ldap_groups'): RecordService @@ -50,4 +51,6 @@ export interface TypedPocketBase extends PocketBase { collection(idOrName: 'eventListSlotsWithEntriesCount'): RecordService collection(idOrName: 'eventListSlotEntriesWithUser'): RecordService + + collection(idOrName: 'emails'): RecordService } \ No newline at end of file diff --git a/src/pages/chat/EventListMessagesList.tsx b/src/pages/chat/EventListMessagesList.tsx deleted file mode 100644 index 42e4ebb..0000000 --- a/src/pages/chat/EventListMessagesList.tsx +++ /dev/null @@ -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 - { - ({isActive}) => <> - -
- {eventList.name} ({event.name}) -
- - } -
-} - -const AnnouncementsLink = () => { - return
- - { - ({isActive}) => <> - - - - - -
- Ankündigungen -
- - } -
- - - { - ({isActive}) => <> - - - - - -
- Gesendete Ankündigungen -
- - } -
-
-} - -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
- - - - - } - rightSection={query.isPending ? : undefined} - defaultValue={chatEntriesQuery} - onChange={(e) => setChatEntriesQuery(e.currentTarget.value)} - placeholder={"Nach Listen suchen..."} - /> - - {chatEntries.length === 0 ? - - - - - - {chatEntriesQuery ? "Keine Listen gefunden" : "Keine Listen"} - - : ( -
- {chatEntries.map(entry => )} - - {query.hasNextPage && ( -
- -
- )} -
- )} -
-} \ No newline at end of file diff --git a/src/pages/chat/ListMessagesView.module.css b/src/pages/chat/ListMessagesView.module.css deleted file mode 100644 index 6b89a86..0000000 --- a/src/pages/chat/ListMessagesView.module.css +++ /dev/null @@ -1,5 +0,0 @@ -.backIcon { - @media (min-width: 768px) { - display: none; - } -} \ No newline at end of file diff --git a/src/pages/chat/ListMessagesView.tsx b/src/pages/chat/ListMessagesView.tsx deleted file mode 100644 index f9bf298..0000000 --- a/src/pages/chat/ListMessagesView.tsx +++ /dev/null @@ -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 ( - - ) - - if (query.isLoading || !query.data) return ( -
- -
- ) - - const eventList = query.data - - return
- - - - - - -
- {eventList.name} - {eventList.expand?.event.name} -
- - {showListInfo ? : } - -
- - -
- - { - (canEditEvent || eventList.expand?.event.privilegedLists.includes(eventList.id)) - && - - - - } -
-
- - -
-} \ No newline at end of file diff --git a/src/pages/chat/components/Announcement.module.css b/src/pages/chat/components/Announcement.module.css deleted file mode 100644 index b331de3..0000000 --- a/src/pages/chat/components/Announcement.module.css +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/src/pages/chat/components/Announcement.tsx b/src/pages/chat/components/Announcement.tsx deleted file mode 100644 index 704882a..0000000 --- a/src/pages/chat/components/Announcement.tsx +++ /dev/null @@ -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
- -
- {announcement.subject &&
- {announcement.subject} -
} - - - von {senderName} • {pprintDate(announcement.created)} • {humanDeltaFromNow(announcement.created).message} - -
- - - - - - -
- - {(eventId && entriesQuery.data?.totalItems !== 0) && ( - entriesQuery.isFetching ?
: - - - Du hast bei dem Event - {" "} - { - entriesQuery.data?.items[0].eventName - } - {" "} - { - entriesQuery.data?.totalItems === 1 ? "einen Eintrag" : `${entriesQuery.data?.totalItems} Einträge` - } - {"."} - - - {entriesQuery.data?.items.map((entry) => ( - - {entry.listName} - {", Zeitslot "} - {pprintDateRange(entry.slotStartDate, entry.slotEndDate)} - {", "} - {humanDeltaFromNow(entry.slotStartDate).message} - - ))} - - - Hier kannst du - alle deine Einträge einsehen und bearbeiten. - - )} - - -
-} \ No newline at end of file diff --git a/src/pages/chat/components/Announcements.module.css b/src/pages/chat/components/Announcements.module.css deleted file mode 100644 index b24a26e..0000000 --- a/src/pages/chat/components/Announcements.module.css +++ /dev/null @@ -1,7 +0,0 @@ -.announcements { - flex-grow: 1; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: var(--gap); -} \ No newline at end of file diff --git a/src/pages/chat/components/Announcements.tsx b/src/pages/chat/components/Announcements.tsx deleted file mode 100644 index 937c5d7..0000000 --- a/src/pages/chat/components/Announcements.tsx +++ /dev/null @@ -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 ( - - ) - - if (query.isLoading || !query.data) return ( -
- -
- ) - - return
-
- {announcements.map((announcement) => ( - - ))} - - {query.hasNextPage ? ( -
- -
- ) : announcements.length === 0 ?
- - Noch keine Ankündigungen - -
: null - } -
-
-} \ No newline at end of file diff --git a/src/pages/chat/components/ChatNavIcon.tsx b/src/pages/chat/components/ChatNavIcon.tsx deleted file mode 100644 index 2d8ff1c..0000000 --- a/src/pages/chat/components/ChatNavIcon.tsx +++ /dev/null @@ -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(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({ - 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 <> - - - - - - -} \ No newline at end of file diff --git a/src/pages/chat/components/Messages.module.css b/src/pages/chat/components/Messages.module.css deleted file mode 100644 index 6ee4c10..0000000 --- a/src/pages/chat/components/Messages.module.css +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/src/pages/chat/components/Messages.tsx b/src/pages/chat/components/Messages.tsx deleted file mode 100644 index 17bafbc..0000000 --- a/src/pages/chat/components/Messages.tsx +++ /dev/null @@ -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({ - 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
- - -
- {messages.map((message) => ( -
- -
- {getUserName(message.expand?.sender)} -
- - - - {message.comment && <> - - - } - -
- {humanDeltaFromNow(message.created).message} • {pprintDateTime(message.created)} -
-
- ))} - - {query.hasNextPage ? ( -
- -
- ) :
- - { - messages.length > 0 ? - "Keine weiteren Nachrichten" - : "Noch keine Nachrichten" - } - -
} -
- - - -
mutation.mutate())}> - - formValues.setFieldValue("content", value)} - modEnter={() => { - if (!formValues.validate().hasErrors) { - mutation.mutate() - } - }} - /> -
- - - -
-
-
-
-} \ No newline at end of file diff --git a/src/pages/chat/components/SendAnnouncements.tsx b/src/pages/chat/components/SendAnnouncements.tsx deleted file mode 100644 index fc5f956..0000000 --- a/src/pages/chat/components/SendAnnouncements.tsx +++ /dev/null @@ -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 ( - - ) - - if (query.isLoading || !query.data) return ( -
- -
- ) - - return
-
- {announcements.map((announcement) => ( - - ))} - - {query.hasNextPage ? ( -
- -
- ) : announcements.length === 0 ?
- - Noch keine von dir gesendeten Ankündigungen - -
: null - } -
-
-} \ No newline at end of file diff --git a/src/pages/chat/ChatRouter.module.css b/src/pages/email/EmailRouter.module.css similarity index 100% rename from src/pages/chat/ChatRouter.module.css rename to src/pages/email/EmailRouter.module.css diff --git a/src/pages/chat/ChatRouter.tsx b/src/pages/email/EmailRouter.tsx similarity index 54% rename from src/pages/chat/ChatRouter.tsx rename to src/pages/email/EmailRouter.tsx index 58ab32a..4f67087 100644 --- a/src/pages/chat/ChatRouter.tsx +++ b/src/pages/email/EmailRouter.tsx @@ -1,24 +1,23 @@ import {Anchor, Breadcrumbs, Center} from "@mantine/core"; 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 EventListMessagesList from "@/pages/chat/EventListMessagesList.tsx"; -import ListMessagesView from "@/pages/chat/ListMessagesView.tsx"; +import SentEmailsNavigation from "@/pages/email/SentEmailsNavigation.tsx"; import {usePB} from "@/lib/pocketbase.tsx"; import {useLogin} from "@/components/users/modals/hooks.ts"; -import Announcements from "@/pages/chat/components/Announcements.tsx"; 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
- +
} -export default function ChatRouter() { +export default function EmailRouter() { const isMobile = useMediaQuery("(max-width: 768px)") const {user} = usePB() @@ -38,7 +37,7 @@ export default function ChatRouter() {
{[ {title: "Home", to: "/"}, - {title: "Nachrichten", to: "/chat"}, + {title: "Email", to: "/email"}, ].map(({title, to}) => ( {title} @@ -50,14 +49,13 @@ export default function ChatRouter() { - }/> + }/> - {!isMobile && }/>} - }/> - }/> - }/> + {!isMobile && }/>} + }/> + }/>
diff --git a/src/pages/email/EmailView/index.module.css b/src/pages/email/EmailView/index.module.css new file mode 100644 index 0000000..723e07a --- /dev/null +++ b/src/pages/email/EmailView/index.module.css @@ -0,0 +1,7 @@ +.infoText { + color: var(--mantine-color-dimmed); +} + +.container { + overflow: auto !important; +} \ No newline at end of file diff --git a/src/pages/email/EmailView/index.tsx b/src/pages/email/EmailView/index.tsx new file mode 100644 index 0000000..a5b30c6 --- /dev/null +++ b/src/pages/email/EmailView/index.tsx @@ -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 ( + + ) + + if (query.isLoading || !query.data) return ( +
+ +
+ ) + + const email = query.data + + const wasSentToCount = email.recipients.filter(r => email.sentTo?.includes(r)).length + + return
+
+ { + email.sentAt && wasSentToCount === 0 ? <> + + + + + + + : email.sentAt ? <> + + + + + + : <> + + + + } + +
+ + {email.subject} + + + + + + }> + {wasSentToCount} gesendet / {email.recipients.length} insgesamt + +
+
+ +
+ + Empfängerinnen + + + Das versenden einer Email kann einige Zeit in Anspruch nehmen. +
+ Falls eine Email an einzelne Personen nicht gesendet wurde, kann es sein, + dass deren Email-Adresse inkorrekt ist. +
+ + + + + + } + > + {email.expand?.recipients?.map(r => { + + const wasSent = email.sentTo?.includes(r.id) + const inProgress = !email.sentAt + + return <> + + + { + wasSent ? : inProgress ? : + } + + + } + > + {getUserName(r)} + + + + })} + + +
+ +
+ + Inhalt + + + +
+
+} \ No newline at end of file diff --git a/src/pages/chat/EventListMessagesList.module.css b/src/pages/email/SentEmailsNavigation.module.css similarity index 68% rename from src/pages/chat/EventListMessagesList.module.css rename to src/pages/email/SentEmailsNavigation.module.css index ae96663..cf013f4 100644 --- a/src/pages/chat/EventListMessagesList.module.css +++ b/src/pages/email/SentEmailsNavigation.module.css @@ -3,27 +3,30 @@ transition: width 0.3s ease } -.announcementLinkContainer { +.newEmailBtnContainer { display: flex; flex-direction: column; padding: calc(var(--padding) / 2); border: var(--border); border-radius: var(--border-radius); background-color: var(--mantine-color-body); + + &[data-active="true"] { + border-color: var(--mantine-primary-color-5); + } } -.announcementLink { +.newEmailBtn { display: flex; flex-direction: row; align-items: center; - padding: calc(var(--padding) / 2); gap: var(--gap); } -.listText { - color: var(--mantine-color-dimmed); +.emailSubject { + + width: calc(100%); - width: calc(100% - 40px); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -34,15 +37,23 @@ } } -.listLink { - display: flex; - flex-direction: row; - align-items: center; - padding: calc(var(--padding) / 2); - gap: var(--gap); +.emailSentDate { + color: var(--mantine-color-dimmed); + font-size: var(--mantine-font-size-xs); + + &[data-active="true"] { + 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; overflow: auto; border: var(--border); diff --git a/src/pages/email/SentEmailsNavigation.tsx b/src/pages/email/SentEmailsNavigation.tsx new file mode 100644 index 0000000..f5438c0 --- /dev/null +++ b/src/pages/email/SentEmailsNavigation.tsx @@ -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 + { + ({isActive}) => <> +
+ {email.subject} +
+
+ {pprintDateTime(email.created)} +
+ + } +
+} + +const NewEmailButton = () => { + const [showModal, showModalHandler] = useDisclosure(false) + + return <> + +
+ + + + + +
+ Neue Email +
+
+
+ +} + +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
+ + + + + } + rightSection={query.isPending ? : undefined} + defaultValue={emailQuery} + onChange={(e) => setEmailQuery(e.currentTarget.value)} + placeholder={"Nach Emails suchen..."} + /> + + {emails.length === 0 ? + + + + + + {emailQuery ? "Keine Emails gefunden" : "Du hast bisher keine Emails gesendet"} + + : ( +
+ {emails.map(entry => )} + + {query.hasNextPage && ( +
+ +
+ )} +
+ )} +
+} \ No newline at end of file diff --git a/src/pages/events/EventNavigate.tsx b/src/pages/events/EventNavigate.tsx index f7841be..252ad6c 100644 --- a/src/pages/events/EventNavigate.tsx +++ b/src/pages/events/EventNavigate.tsx @@ -19,11 +19,11 @@ export default function EventNavigate() { const eventQuery = useQuery({ queryKey: ["event", 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) { return @@ -37,9 +37,5 @@ export default function EventNavigate() { return } - if (isPrivilegedUser) { - return - } - return } \ No newline at end of file diff --git a/src/pages/events/EventOverview/CreateEvent.tsx b/src/pages/events/EventOverview/CreateEvent.tsx index 0880ca1..911bc6a 100644 --- a/src/pages/events/EventOverview/CreateEvent.tsx +++ b/src/pages/events/EventOverview/CreateEvent.tsx @@ -4,7 +4,7 @@ import {DateTimePicker} from "@mantine/dates"; import {IconCheck, IconInfoCircle, IconX} from "@tabler/icons-react"; import {useMutation} from "@tanstack/react-query"; import dayjs from "dayjs"; -import {UserModal} from "@/models/AuthTypes.ts"; +import {UserModel} from "@/models/AuthTypes.ts"; import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx"; import UserInput from "@/components/users/UserInput.tsx"; import {EventModel} from "@/models/EventTypes.ts"; @@ -33,13 +33,13 @@ export default function CreateEvent({onSuccess, onAbort}: { name: "", startDate: null, endDate: null, - eventAdmins: [user] as UserModal[], + eventAdmins: [user] as UserModel[], isStuveEvent: true, }, validate: { 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.", - endDate: (value, values) => dayjs(value).isAfter(dayjs(values.startDate), "day") ? null : "Das Enddatum muss nach dem Startdatum 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") || 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.", } }) diff --git a/src/pages/events/EventOverview/EventList.tsx b/src/pages/events/EventOverview/EventList.tsx index 361e618..0a6d8f8 100644 --- a/src/pages/events/EventOverview/EventList.tsx +++ b/src/pages/events/EventOverview/EventList.tsx @@ -24,7 +24,6 @@ import { IconHistory, IconHistoryOff, IconInfoCircle, - IconList, IconSearch, IconSortAscending, IconSortDescending, @@ -43,7 +42,7 @@ import {useEventRights} from "@/pages/events/util.ts"; */ const EventRow = ({event}: { event: EventModel }) => { - const {isPrivilegedUser, canEditEvent} = useEventRights(event) + const {canEditEvent} = useEventRights(event) const [opened, handlers] = useDisclosure(false) @@ -108,26 +107,14 @@ const EventRow = ({event}: { event: EventModel }) => { - ) : isPrivilegedUser ? - - - - - : ( - - ) + ) : ( + + ) ) } @@ -187,7 +174,6 @@ export const EventList = () => { return await pb.collection("events").getList(activePage, 10, { sort: sort, filter: [`hideFromPublic = false`, ...filter].join(" && "), - expand: "privilegedLists.eventListSlots_via_eventList.eventListSlotEntries_via_eventListsSlot" } ) } diff --git a/src/pages/events/StatusEditor/StatusEditor.tsx b/src/pages/events/StatusEditor/StatusEditor.tsx index 3192ece..5de60c5 100644 --- a/src/pages/events/StatusEditor/StatusEditor.tsx +++ b/src/pages/events/StatusEditor/StatusEditor.tsx @@ -15,11 +15,11 @@ export default function StatusEditor() { const eventQuery = useQuery({ queryKey: ["event", 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) { return @@ -29,7 +29,7 @@ export default function StatusEditor() { return } - if (!(canEditEvent || isPrivilegedUser)) { + if (!(canEditEvent)) { return } diff --git a/src/pages/events/e/:eventId/EditEventRouter.tsx b/src/pages/events/e/:eventId/EditEventRouter.tsx index 59a18bc..542f150 100644 --- a/src/pages/events/e/:eventId/EditEventRouter.tsx +++ b/src/pages/events/e/:eventId/EditEventRouter.tsx @@ -89,11 +89,11 @@ export default function EditEventRouter() { const eventQuery = useQuery({ queryKey: ["event", 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) { return @@ -103,7 +103,7 @@ export default function EditEventRouter() { return } - if (!(canEditEvent || isPrivilegedUser)) { + if (!(canEditEvent)) { return } diff --git a/src/pages/events/e/:eventId/EventComponents/EventData.tsx b/src/pages/events/e/:eventId/EventComponents/EventData.tsx index e2c903d..2740cf0 100644 --- a/src/pages/events/e/:eventId/EventComponents/EventData.tsx +++ b/src/pages/events/e/:eventId/EventComponents/EventData.tsx @@ -1,9 +1,9 @@ import {EventModel} from "@/models/EventTypes.ts"; 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 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 @@ -86,21 +86,6 @@ export default function EventData({event, hideHeader}: { event: EventModel, hide } - - { - event.expand?.privilegedLists && - - }> - { - event.expand?.privilegedLists.map((l) => ( - - {l.name} - - )) - } - - - } ) diff --git a/src/pages/events/e/:eventId/EventComponents/EventSettings/EditEventMembers.tsx b/src/pages/events/e/:eventId/EventComponents/EventSettings/EditEventMembers.tsx index 843e37f..8a4f28f 100644 --- a/src/pages/events/e/:eventId/EventComponents/EventSettings/EditEventMembers.tsx +++ b/src/pages/events/e/:eventId/EventComponents/EventSettings/EditEventMembers.tsx @@ -1,6 +1,6 @@ -import {EventListModel, EventModel} from "@/models/EventTypes.ts"; +import {EventModel} from "@/models/EventTypes.ts"; 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 {Button, Group, Title} from "@mantine/core"; import UserInput from "@/components/users/UserInput.tsx"; @@ -8,7 +8,6 @@ import {useMutation} from "@tanstack/react-query"; import {queryClient} from "@/main.tsx"; import {showSuccessNotification} from "@/components/util.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. @@ -20,8 +19,7 @@ export default function EditEventMembers({event}: { event: EventModel }) { const formValues = useForm({ initialValues: { - eventAdmins: event?.expand?.eventAdmins ?? [user] as UserModal[], - privilegedLists: event?.expand?.privilegedLists ?? [] as EventListModel[] + eventAdmins: event?.expand?.eventAdmins ?? [user] as UserModel[], }, validate: { 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 () => { return await pb.collection("events").update(event.id, { eventAdmins: [...formValues.values.eventAdmins.map((member) => member.id), user?.id], - privilegedLists: formValues.values.privilegedLists.map((member) => member.id) }) }, onSuccess: () => { @@ -45,20 +42,8 @@ export default function EditEventMembers({event}: { event: EventModel }) { Event Admins - Event Admins Event Admin können alle Einstellungen des Events bearbeiten. Sie können auch alle Listen bearbeiten und verwalten. -
-
- - Privilegierte Listen - Alle Teilnehmenden in privilegierten Listen können alle Anmeldungen von allen Listen - dieses Events sehen und den Status von allen Teilnehmenden bearbeiten. -
- Du kannst eine privilegierte Liste z.B. für die Event-Orgs erstellen, so dass diese - alle Anmeldungen sehen und bearbeiten können. -
-
@@ -72,14 +57,6 @@ export default function EditEventMembers({event}: { event: EventModel }) { setSelectedRecords={(records) => formValues.setFieldValue("eventAdmins", records)} /> - formValues.setFieldValue("privilegedLists", records)} - /> -