feat(chat): added new chat feature
Build and Push Docker image / build-and-push (push) Successful in 5m32s
Details
Build and Push Docker image / build-and-push (push) Successful in 5m32s
Details
you can now enable chat rooms for eventLists
This commit is contained in:
parent
7cef873acd
commit
390edb38bd
|
@ -9,5 +9,5 @@ export const PB_STORAGE_KEY = "stuve-it-login-record"
|
||||||
|
|
||||||
// general
|
// general
|
||||||
export const APP_NAME = "StuVe IT"
|
export const APP_NAME = "StuVe IT"
|
||||||
export const APP_VERSION = "0.8.11 (beta)"
|
export const APP_VERSION = "0.9.0 (beta)"
|
||||||
export const APP_URL = "https://it.stuve.uni-ulm.de"
|
export const APP_URL = "https://it.stuve.uni-ulm.de"
|
|
@ -9,7 +9,6 @@ import RegisterModal from "@/components/users/modals/RegisterModal.tsx";
|
||||||
import EmailTokenVerification from "@/components/users/modals/EmailTokenVerification.tsx";
|
import EmailTokenVerification from "@/components/users/modals/EmailTokenVerification.tsx";
|
||||||
import ForgotPasswordModal from "@/components/users/modals/ForgotPasswordModal.tsx";
|
import ForgotPasswordModal from "@/components/users/modals/ForgotPasswordModal.tsx";
|
||||||
import ChangeEmailModal from "@/components/users/modals/ChangeEmailModal.tsx";
|
import ChangeEmailModal from "@/components/users/modals/ChangeEmailModal.tsx";
|
||||||
import ShowMessagesModal from "@/components/users/modals/ShowMessagesModal";
|
|
||||||
|
|
||||||
export default function Layout({hideNav}: { hideNav?: boolean }) {
|
export default function Layout({hideNav}: { hideNav?: boolean }) {
|
||||||
return <div className={classes.container}>
|
return <div className={classes.container}>
|
||||||
|
@ -21,7 +20,6 @@ export default function Layout({hideNav}: { hideNav?: boolean }) {
|
||||||
<EmailTokenVerification/>
|
<EmailTokenVerification/>
|
||||||
<ForgotPasswordModal/>
|
<ForgotPasswordModal/>
|
||||||
<ChangeEmailModal/>
|
<ChangeEmailModal/>
|
||||||
<ShowMessagesModal/>
|
|
||||||
|
|
||||||
<div className={`${classes.body}`}>
|
<div className={`${classes.body}`}>
|
||||||
<div className={`${classes.content} no-scrollbar`}>
|
<div className={`${classes.content} no-scrollbar`}>
|
||||||
|
|
|
@ -1,26 +0,0 @@
|
||||||
import {MessagesModel} from "@/models/MessageTypes.ts";
|
|
||||||
import {Notification} from '@mantine/core';
|
|
||||||
import InnerHtml from "@/components/InnerHtml";
|
|
||||||
import {getUserName} from "@/components/users/modals/util.tsx";
|
|
||||||
|
|
||||||
export default function Message({message}: { message: MessagesModel }) {
|
|
||||||
|
|
||||||
|
|
||||||
const senderName = getUserName(message.expand?.sender)
|
|
||||||
|
|
||||||
let title
|
|
||||||
|
|
||||||
if (message.subject) {
|
|
||||||
title = `${senderName} - ${message.subject}`
|
|
||||||
} else {
|
|
||||||
title = senderName
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>
|
|
||||||
<Notification title={
|
|
||||||
title
|
|
||||||
} withCloseButton={false} radius="lg">
|
|
||||||
<InnerHtml html={message.content ?? ""}/>
|
|
||||||
</Notification>
|
|
||||||
</>
|
|
||||||
}
|
|
|
@ -1,5 +0,0 @@
|
||||||
.messagesContainer {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column-reverse;
|
|
||||||
gap: var(--gap);
|
|
||||||
}
|
|
|
@ -1,109 +0,0 @@
|
||||||
import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx";
|
|
||||||
import {Button, Modal, Stack, Text, ThemeIcon} from "@mantine/core";
|
|
||||||
import {useShowMessages} from "@/components/users/modals/hooks.ts";
|
|
||||||
import PromptLoginModal from "@/components/users/modals/PromptLoginModal.tsx";
|
|
||||||
import {useInfiniteQuery} from "@tanstack/react-query";
|
|
||||||
import {IconMessageCircleOff, IconMessageCircleUp} from "@tabler/icons-react";
|
|
||||||
import Message from "@/components/users/modals/ShowMessagesModal/Message.tsx";
|
|
||||||
import InfiniteScroll from "react-infinite-scroll-component";
|
|
||||||
|
|
||||||
|
|
||||||
export default function ShowMessagesModal() {
|
|
||||||
|
|
||||||
const {value, handler} = useShowMessages()
|
|
||||||
const {user, pb} = usePB()
|
|
||||||
|
|
||||||
const PER_PAGE = 1 // todo
|
|
||||||
|
|
||||||
const {
|
|
||||||
fetchNextPage,
|
|
||||||
hasNextPage,
|
|
||||||
isFetchingNextPage,
|
|
||||||
...query
|
|
||||||
} = useInfiniteQuery({
|
|
||||||
queryKey: ["messages", user?.id, "infinite"],
|
|
||||||
queryFn: async ({pageParam}) => (
|
|
||||||
await pb.collection("messages").getList(pageParam, PER_PAGE, {
|
|
||||||
filter: `recipients?~'${user?.id}'&&thread=null`,
|
|
||||||
sort: "-created",
|
|
||||||
expand: "sender"
|
|
||||||
})
|
|
||||||
),
|
|
||||||
initialPageParam: 1,
|
|
||||||
getNextPageParam: (lastPage) =>
|
|
||||||
lastPage.page >= lastPage.totalPages ? undefined : lastPage.page + 1,
|
|
||||||
enabled: !!user
|
|
||||||
})
|
|
||||||
|
|
||||||
if (value && !user) {
|
|
||||||
return <PromptLoginModal
|
|
||||||
onAbort={handler.close}
|
|
||||||
description={"Du musst eingeloggt sein um deine Nachrichten zu sehen."}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>
|
|
||||||
<Modal
|
|
||||||
styles={{
|
|
||||||
content: {minHeight: '100%'},
|
|
||||||
}}
|
|
||||||
opened={value}
|
|
||||||
onClose={handler.close}
|
|
||||||
title={"Nachrichten"}
|
|
||||||
size={"lg"}
|
|
||||||
>
|
|
||||||
<div className={"stack"}>
|
|
||||||
<PocketBaseErrorAlert error={query.error}/>
|
|
||||||
|
|
||||||
{
|
|
||||||
query.data?.pages[0].totalItems === 0 && <Stack align={"center"}>
|
|
||||||
<ThemeIcon size={"xl"} variant={"transparent"} color={"gray"}>
|
|
||||||
<IconMessageCircleOff/>
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text c={"dimmed"} size={"xs"}>
|
|
||||||
Keine Nachrichten
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
}
|
|
||||||
|
|
||||||
<InfiniteScroll
|
|
||||||
dataLength={query.data?.pages.length ?? 0}
|
|
||||||
next={fetchNextPage}
|
|
||||||
hasMore={hasNextPage}
|
|
||||||
loader={<h4>Loading...</h4>}
|
|
||||||
endMessage={
|
|
||||||
<p style={{textAlign: 'center'}}>
|
|
||||||
<b>Yay! You have seen it all</b>
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
refreshFunction={query.refetch}
|
|
||||||
pullDownToRefresh
|
|
||||||
pullDownToRefreshThreshold={50}
|
|
||||||
pullDownToRefreshContent={
|
|
||||||
<h3 style={{textAlign: 'center'}}>↓ Pull down to refresh</h3>
|
|
||||||
}
|
|
||||||
releaseToRefreshContent={
|
|
||||||
<h3 style={{textAlign: 'center'}}>↑ Release to refresh</h3>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
{
|
|
||||||
query.data?.pages.map(page => page?.items.map((message) => (
|
|
||||||
<Message message={message} key={message.id}/>
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
leftSection={<IconMessageCircleUp/>}
|
|
||||||
disabled={!hasNextPage}
|
|
||||||
onClick={() => fetchNextPage()}
|
|
||||||
loading={isFetchingNextPage}
|
|
||||||
>
|
|
||||||
Mehr Nachrichten laden
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</InfiniteScroll>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
}
|
|
|
@ -36,6 +36,7 @@ export type EventListModel = {
|
||||||
favourite: boolean | null;
|
favourite: boolean | null;
|
||||||
allowOverlappingEntries: boolean | null;
|
allowOverlappingEntries: boolean | null;
|
||||||
onlyStuVeAccounts: boolean | null;
|
onlyStuVeAccounts: boolean | null;
|
||||||
|
enableChat: boolean | null;
|
||||||
event: string
|
event: string
|
||||||
entryQuestionSchema: FormSchema | null
|
entryQuestionSchema: FormSchema | null
|
||||||
entryStatusSchema: FormSchema | null;
|
entryStatusSchema: FormSchema | null;
|
||||||
|
|
|
@ -1,13 +1,15 @@
|
||||||
import {RecordModel} from "pocketbase";
|
import {RecordModel} from "pocketbase";
|
||||||
import {UserModal} from "@/models/AuthTypes.ts";
|
import {UserModal} from "@/models/AuthTypes.ts";
|
||||||
|
import {EventListModel} from "@/models/EventTypes.ts";
|
||||||
|
|
||||||
export type MessagesModel = {
|
export type MessagesModel = {
|
||||||
sender: string
|
sender: string
|
||||||
recipients: string[]
|
recipient: string | null
|
||||||
thread: string | null
|
eventList: string | null
|
||||||
|
|
||||||
subject: string | null
|
subject: string | null
|
||||||
content: string
|
content: string
|
||||||
|
comment: string | null
|
||||||
|
|
||||||
repliedTo: string | null
|
repliedTo: string | null
|
||||||
|
|
||||||
|
@ -15,19 +17,8 @@ export type MessagesModel = {
|
||||||
|
|
||||||
expand: {
|
expand: {
|
||||||
sender: UserModal
|
sender: UserModal
|
||||||
recipients: UserModal[]
|
recipient: UserModal
|
||||||
thread: MessageThreadsModel | null
|
eventList: EventListModel | null
|
||||||
repliedTo: MessagesModel | null
|
repliedTo: MessagesModel | null
|
||||||
}
|
}
|
||||||
} & RecordModel
|
} & RecordModel
|
||||||
|
|
||||||
export type MessageThreadsModel = {
|
|
||||||
name: string
|
|
||||||
participants: string[]
|
|
||||||
img: string | null
|
|
||||||
systemThread: boolean | null
|
|
||||||
|
|
||||||
expand: {
|
|
||||||
participants: UserModal[]
|
|
||||||
}
|
|
||||||
} & RecordModel
|
|
||||||
|
|
|
@ -8,7 +8,7 @@ import {
|
||||||
EventListSlotsWithEntriesCountModel,
|
EventListSlotsWithEntriesCountModel,
|
||||||
EventModel
|
EventModel
|
||||||
} from "./EventTypes.ts";
|
} from "./EventTypes.ts";
|
||||||
import {MessagesModel, MessageThreadsModel} from "@/models/MessageTypes.ts";
|
import {MessagesModel} from "@/models/MessageTypes.ts";
|
||||||
|
|
||||||
export type SettingsModel = {
|
export type SettingsModel = {
|
||||||
key: ['privacyPolicy', 'agb', 'stexGroup', 'stuveEventQuestions']
|
key: ['privacyPolicy', 'agb', 'stexGroup', 'stuveEventQuestions']
|
||||||
|
@ -29,8 +29,6 @@ export interface TypedPocketBase extends PocketBase {
|
||||||
|
|
||||||
collection(idOrName: 'messages'): RecordService<MessagesModel>
|
collection(idOrName: 'messages'): RecordService<MessagesModel>
|
||||||
|
|
||||||
collection(idOrName: 'messageThreads'): RecordService<MessageThreadsModel>
|
|
||||||
|
|
||||||
collection(idOrName: 'settings'): RecordService<SettingsModel>
|
collection(idOrName: 'settings'): RecordService<SettingsModel>
|
||||||
|
|
||||||
collection(idOrName: 'legalSettings'): RecordService<LegalSettingsModal>
|
collection(idOrName: 'legalSettings'): RecordService<LegalSettingsModal>
|
||||||
|
|
|
@ -4,8 +4,8 @@ import classes from "./ChatRouter.module.css";
|
||||||
|
|
||||||
import ConversationSvg from "@/illustrations/conversation.svg?react";
|
import ConversationSvg from "@/illustrations/conversation.svg?react";
|
||||||
import {useMediaQuery} from "@mantine/hooks";
|
import {useMediaQuery} from "@mantine/hooks";
|
||||||
import MessageThreadsList from "@/pages/chat/MessageThreadsList.tsx";
|
import EventListMessagesList from "@/pages/chat/EventListMessagesList.tsx";
|
||||||
import MessageThreadView from "@/pages/chat/MessageThreadView.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 Announcements from "@/pages/chat/components/Announcements.tsx";
|
||||||
|
@ -34,7 +34,7 @@ export default function ChatRouter() {
|
||||||
{title: "Home", to: "/"},
|
{title: "Home", to: "/"},
|
||||||
{title: "Nachrichten", to: "/chat"},
|
{title: "Nachrichten", to: "/chat"},
|
||||||
].map(({title, to}) => (
|
].map(({title, to}) => (
|
||||||
<Anchor component={Link} to={to} key={title}>
|
<Anchor component={Link} to={to} key={title} className={"wrapWords"}>
|
||||||
{title}
|
{title}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
))}</Breadcrumbs>
|
))}</Breadcrumbs>
|
||||||
|
@ -44,12 +44,12 @@ export default function ChatRouter() {
|
||||||
<Outlet/>
|
<Outlet/>
|
||||||
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path={isMobile ? "/" : "*"} element={<MessageThreadsList/>}/>
|
<Route path={isMobile ? "/" : "*"} element={<EventListMessagesList/>}/>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
||||||
<Routes>
|
<Routes>
|
||||||
{!isMobile && <Route index element={<ChatIndex/>}/>}
|
{!isMobile && <Route index element={<ChatIndex/>}/>}
|
||||||
<Route path={":threadId"} element={<MessageThreadView/>}/>
|
<Route path={":listId"} element={<ListMessagesView/>}/>
|
||||||
<Route path={"announcements"} element={<Announcements/>}/>
|
<Route path={"announcements"} element={<Announcements/>}/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
background-color: var(--mantine-color-body);
|
background-color: var(--mantine-color-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
.threadText {
|
.listText {
|
||||||
color: var(--mantine-color-dimmed);
|
color: var(--mantine-color-dimmed);
|
||||||
|
|
||||||
width: calc(100% - 40px);
|
width: calc(100% - 40px);
|
||||||
|
@ -28,7 +28,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.threadLink {
|
.listLink {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -36,7 +36,7 @@
|
||||||
gap: var(--gap);
|
gap: var(--gap);
|
||||||
}
|
}
|
||||||
|
|
||||||
.threadsContainer {
|
.listsContainer {
|
||||||
//flex-grow: 1;
|
//flex-grow: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border: var(--border);
|
border: var(--border);
|
|
@ -0,0 +1,107 @@
|
||||||
|
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, 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} from "@/models/EventTypes.ts";
|
||||||
|
|
||||||
|
const ListMessagesLink = ({eventList}: { eventList: EventListModel }) => {
|
||||||
|
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} ({eventList.expand?.event.name})
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</UnstyledButton>
|
||||||
|
}
|
||||||
|
|
||||||
|
const AnnouncementsLink = () => {
|
||||||
|
return <>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EventListMessagesList() {
|
||||||
|
const {user, pb} = usePB()
|
||||||
|
|
||||||
|
const [eventListNameQuery, setEventListNameQuery] = useDebouncedState("", 200)
|
||||||
|
|
||||||
|
const query = useInfiniteQuery({
|
||||||
|
queryKey: ["eventLists", eventListNameQuery],
|
||||||
|
queryFn: async ({pageParam}) => (
|
||||||
|
await pb.collection("eventLists").getList(pageParam, 100, {
|
||||||
|
filter: `enableChat=true&&name~'${eventListNameQuery}'`,
|
||||||
|
sort: "-messages_via_eventList.created,name",
|
||||||
|
expand: "event"
|
||||||
|
})
|
||||||
|
),
|
||||||
|
getNextPageParam: (lastPage) =>
|
||||||
|
lastPage.page >= lastPage.totalPages ? undefined : lastPage.page + 1,
|
||||||
|
initialPageParam: 1,
|
||||||
|
enabled: !!user,
|
||||||
|
})
|
||||||
|
|
||||||
|
const eventLists = 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={eventListNameQuery}
|
||||||
|
onChange={(e) => setEventListNameQuery(e.currentTarget.value)}
|
||||||
|
placeholder={"Nach Listen suchen..."}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{eventLists.length === 0 ? <Stack gap={"xs"} align={"center"}>
|
||||||
|
<ThemeIcon variant="transparent" size="xs" color="gray">
|
||||||
|
<IconMoodPuzzled/>
|
||||||
|
</ThemeIcon>
|
||||||
|
|
||||||
|
<Text size={"xs"} c={"dimmed"}>
|
||||||
|
{eventListNameQuery ? "Keine Listen gefunden" : "Keine Listen"}
|
||||||
|
</Text>
|
||||||
|
</Stack> : (
|
||||||
|
<div className={`${classes.listsContainer} no-scrollbar`}>
|
||||||
|
{eventLists.map(list => <ListMessagesLink key={list.id} eventList={list}/>)}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
}
|
|
@ -0,0 +1,92 @@
|
||||||
|
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>
|
||||||
|
}
|
|
@ -1,134 +0,0 @@
|
||||||
import {Link, useNavigate, useParams} from "react-router-dom";
|
|
||||||
import {useMutation, useQuery} from "@tanstack/react-query";
|
|
||||||
import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx";
|
|
||||||
import {ActionIcon, Alert, Button, Center, Collapse, Group, Loader, Text} from "@mantine/core";
|
|
||||||
import PBAvatar from "@/components/PBAvatar.tsx";
|
|
||||||
import {IconChevronLeft, IconEdit, IconInfoCircle, IconTrash} from "@tabler/icons-react";
|
|
||||||
import {useDisclosure} from "@mantine/hooks";
|
|
||||||
import UsersDisplay from "@/components/users/UsersDisplay.tsx";
|
|
||||||
import UpsertThreadForm from "@/pages/chat/components/UpsertThreadForm.tsx";
|
|
||||||
import Messages from "@/pages/chat/components/Messages.tsx";
|
|
||||||
import {useConfirmModal} from "@/components/ConfirmModal.tsx";
|
|
||||||
import classes from './MessageThreadView.module.css';
|
|
||||||
|
|
||||||
export default function MessageThreadView() {
|
|
||||||
const {threadId} = useParams() as { threadId: string }
|
|
||||||
|
|
||||||
const {pb} = usePB()
|
|
||||||
|
|
||||||
const [showEditThread, showEditThreadHandler] = useDisclosure(false)
|
|
||||||
|
|
||||||
const [showThreadInfo, showThreadInfoHandler] = useDisclosure(false)
|
|
||||||
|
|
||||||
const query = useQuery({
|
|
||||||
queryKey: ["messageThreads", threadId],
|
|
||||||
queryFn: async () => (
|
|
||||||
await pb.collection("messageThreads").getOne(threadId, {
|
|
||||||
expand: "participants"
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
const deleteThreadMutation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
await pb.collection("messageThreads").delete(threadId)
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
navigate("/chat")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const {toggleConfirmModal, ConfirmModal} = useConfirmModal({
|
|
||||||
title: "Thread löschen",
|
|
||||||
description: "Bist du sicher, dass du diesen Thread löschen möchtest?",
|
|
||||||
onConfirm: () => deleteThreadMutation.mutate()
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
||||||
if (query.isError) return (
|
|
||||||
<PocketBaseErrorAlert error={query.error}/>
|
|
||||||
)
|
|
||||||
|
|
||||||
if (query.isLoading || !query.data) return (
|
|
||||||
<Center>
|
|
||||||
<Loader/>
|
|
||||||
</Center>
|
|
||||||
)
|
|
||||||
|
|
||||||
const thread = query.data
|
|
||||||
|
|
||||||
return <div className={"stack"}>
|
|
||||||
|
|
||||||
<ConfirmModal/>
|
|
||||||
|
|
||||||
<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={thread} name={thread.name} img={thread.img} size={"lg"}/>
|
|
||||||
<Text size={"lg"} fw={600}>{thread.name}</Text>
|
|
||||||
<ActionIcon
|
|
||||||
variant={"transparent"} color={"blue"}
|
|
||||||
radius={"xl"} ms={"auto"}
|
|
||||||
aria-label={"Thread Info"}
|
|
||||||
onClick={showThreadInfoHandler.toggle}
|
|
||||||
>
|
|
||||||
<IconInfoCircle/>
|
|
||||||
</ActionIcon>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Collapse in={showThreadInfo}>
|
|
||||||
<Alert color={"blue"} title={"Thread Info"}>
|
|
||||||
<div className={"stack"}>
|
|
||||||
{
|
|
||||||
thread.expand.participants && (
|
|
||||||
<UsersDisplay users={thread.expand.participants}/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
{!showEditThread && <Group>
|
|
||||||
|
|
||||||
<ActionIcon
|
|
||||||
variant={"transparent"} color={"red"}
|
|
||||||
radius={"xl"}
|
|
||||||
aria-label={"Delete Thread"}
|
|
||||||
onClick={toggleConfirmModal}
|
|
||||||
>
|
|
||||||
<IconTrash/>
|
|
||||||
</ActionIcon>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant={"light"}
|
|
||||||
color={"blue"}
|
|
||||||
size={"xs"}
|
|
||||||
radius={"xl"}
|
|
||||||
aria-label={"Edit Thread"}
|
|
||||||
onClick={showEditThreadHandler.toggle}
|
|
||||||
leftSection={<IconEdit/>}
|
|
||||||
>
|
|
||||||
Bearbeiten
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
}
|
|
||||||
<Collapse in={showEditThread}>
|
|
||||||
<UpsertThreadForm thread={thread} onSuccess={() => {
|
|
||||||
showEditThreadHandler.toggle()
|
|
||||||
query.refetch()
|
|
||||||
}} onCancel={showEditThreadHandler.toggle}/>
|
|
||||||
</Collapse>
|
|
||||||
</div>
|
|
||||||
</Alert>
|
|
||||||
</Collapse>
|
|
||||||
|
|
||||||
<Messages thread={thread}/>
|
|
||||||
</div>
|
|
||||||
}
|
|
|
@ -1,140 +0,0 @@
|
||||||
import {MessageThreadsModel} from "@/models/MessageTypes.ts";
|
|
||||||
import {
|
|
||||||
ActionIcon,
|
|
||||||
Alert,
|
|
||||||
Button,
|
|
||||||
Center,
|
|
||||||
Collapse,
|
|
||||||
Divider,
|
|
||||||
Loader,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
ThemeIcon,
|
|
||||||
UnstyledButton
|
|
||||||
} from "@mantine/core";
|
|
||||||
import {NavLink} from "react-router-dom";
|
|
||||||
import classes from "@/pages/chat/MessageThreadsList.module.css";
|
|
||||||
import PBAvatar from "@/components/PBAvatar.tsx";
|
|
||||||
import {IconArrowDown, IconMinus, IconNeedleThread, IconPlus, IconSpeakerphone} from "@tabler/icons-react";
|
|
||||||
import {usePB} from "@/lib/pocketbase.tsx";
|
|
||||||
import {useDebouncedState, useDisclosure} from "@mantine/hooks";
|
|
||||||
import {useInfiniteQuery} from "@tanstack/react-query";
|
|
||||||
import UpsertThreadForm from "@/pages/chat/components/UpsertThreadForm.tsx";
|
|
||||||
|
|
||||||
const MessageThreadLink = ({thread}: { thread: MessageThreadsModel }) => {
|
|
||||||
return <UnstyledButton component={NavLink} to={`/chat/${thread.id}`} className={classes.threadLink}>
|
|
||||||
{
|
|
||||||
({isActive}) => <>
|
|
||||||
|
|
||||||
<PBAvatar model={thread} name={thread.name} img={thread.img} size={"sm"}/>
|
|
||||||
|
|
||||||
<div className={classes.threadText} data-active={isActive}>
|
|
||||||
{thread.name}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
</UnstyledButton>
|
|
||||||
}
|
|
||||||
|
|
||||||
const AnnouncementsLink = () => {
|
|
||||||
return <>
|
|
||||||
<UnstyledButton component={NavLink} to={`/chat/announcements`} className={classes.announcementLink}>
|
|
||||||
{
|
|
||||||
({isActive}) => <>
|
|
||||||
|
|
||||||
<ThemeIcon variant="light" radius={"xl"} size="md" color="green">
|
|
||||||
<IconSpeakerphone/>
|
|
||||||
</ThemeIcon>
|
|
||||||
|
|
||||||
<div className={`${classes.threadText} `} data-active={isActive}>
|
|
||||||
Ankündigungen
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
</UnstyledButton>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MessageThreadsList() {
|
|
||||||
const {user, pb} = usePB()
|
|
||||||
|
|
||||||
const [showCreateThread, showCreateThreadHandler] = useDisclosure(false)
|
|
||||||
|
|
||||||
const [threadSearchQuery, setThreadSearchQuery] = useDebouncedState("", 200)
|
|
||||||
|
|
||||||
const query = useInfiniteQuery({
|
|
||||||
queryKey: ["threads", threadSearchQuery],
|
|
||||||
queryFn: async ({pageParam}) => (
|
|
||||||
await pb.collection("messageThreads").getList(pageParam, 100, {
|
|
||||||
filter: `participants ?~ '${user?.id}' && name ~ '${threadSearchQuery}' && systemThread != true`,
|
|
||||||
})
|
|
||||||
),
|
|
||||||
getNextPageParam: (lastPage) =>
|
|
||||||
lastPage.page >= lastPage.totalPages ? undefined : lastPage.page + 1,
|
|
||||||
initialPageParam: 1,
|
|
||||||
enabled: !!user,
|
|
||||||
})
|
|
||||||
|
|
||||||
const threads = query.data?.pages.flatMap(t => t.items) || []
|
|
||||||
|
|
||||||
return <div className={`stack ${classes.container}`}>
|
|
||||||
<AnnouncementsLink/>
|
|
||||||
|
|
||||||
<Divider label={"Threads"}/>
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
leftSection={<IconNeedleThread/>}
|
|
||||||
rightSection={query.isPending ? <Loader size={"xs"}/> : (
|
|
||||||
<ActionIcon
|
|
||||||
onClick={() => showCreateThreadHandler.toggle()}
|
|
||||||
color={"green"}
|
|
||||||
variant={"transparent"}
|
|
||||||
>
|
|
||||||
{showCreateThread ? <IconMinus/> : <IconPlus/>}
|
|
||||||
</ActionIcon>
|
|
||||||
)}
|
|
||||||
defaultValue={threadSearchQuery}
|
|
||||||
onChange={(e) => setThreadSearchQuery(e.currentTarget.value)}
|
|
||||||
placeholder={"Nach Threads suchen..."}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Collapse in={showCreateThread}>
|
|
||||||
<Alert className={"stack"} color={"green"} title={"Neuen Thread erstellen"}>
|
|
||||||
<UpsertThreadForm onSuccess={() => {
|
|
||||||
query.refetch()
|
|
||||||
showCreateThreadHandler.close()
|
|
||||||
}} onCancel={showCreateThreadHandler.close}/>
|
|
||||||
</Alert>
|
|
||||||
</Collapse>
|
|
||||||
|
|
||||||
{threads.length === 0 ? <Stack gap={"xs"} align={"center"}>
|
|
||||||
<ThemeIcon variant="transparent" size="xs" color="gray">
|
|
||||||
<IconNeedleThread/>
|
|
||||||
</ThemeIcon>
|
|
||||||
|
|
||||||
<Text size={"xs"} c={"dimmed"}>
|
|
||||||
{threadSearchQuery ? "Keine Threads gefunden" : "Keine Threads"}
|
|
||||||
</Text>
|
|
||||||
</Stack> : (
|
|
||||||
<div className={`${classes.threadsContainer} no-scrollbar`}>
|
|
||||||
{threads.map(thread => <MessageThreadLink key={thread.id} thread={thread}/>)}
|
|
||||||
|
|
||||||
{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>
|
|
||||||
}
|
|
|
@ -8,7 +8,6 @@ export default function Announcement({subject, content}: {
|
||||||
content: string,
|
content: string,
|
||||||
}) {
|
}) {
|
||||||
return <div className={classes.announcement}>
|
return <div className={classes.announcement}>
|
||||||
|
|
||||||
<Group justify={"space-between"} wrap={"nowrap"} align={"top"} mb={"md"}>
|
<Group justify={"space-between"} wrap={"nowrap"} align={"top"} mb={"md"}>
|
||||||
{subject && <div className={classes.subject}>
|
{subject && <div className={classes.subject}>
|
||||||
{subject}
|
{subject}
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import {useInfiniteQuery} from "@tanstack/react-query";
|
import {useInfiniteQuery} from "@tanstack/react-query";
|
||||||
import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx";
|
import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx";
|
||||||
import {Button, Center, Loader, Text} from "@mantine/core";
|
import {Button, Center, Loader, Text} from "@mantine/core";
|
||||||
|
|
||||||
import classes from './Announcements.module.css'
|
import classes from './Announcements.module.css'
|
||||||
import {IconMessageCircleUp} from "@tabler/icons-react";
|
import {IconMessageCircleUp} from "@tabler/icons-react";
|
||||||
import Announcement from "@/pages/chat/components/Announcement.tsx";
|
import Announcement from "@/pages/chat/components/Announcement.tsx";
|
||||||
|
@ -13,7 +12,7 @@ export default function Announcements() {
|
||||||
queryKey: ["announcements"],
|
queryKey: ["announcements"],
|
||||||
queryFn: async ({pageParam}) => (
|
queryFn: async ({pageParam}) => (
|
||||||
await pb.collection("messages").getList(pageParam, 100, {
|
await pb.collection("messages").getList(pageParam, 100, {
|
||||||
filter: `isAnnouncement=true`,
|
filter: `isAnnouncement=true&&sender!='${user?.id}'`,
|
||||||
sort: "-created"
|
sort: "-created"
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
|
|
|
@ -11,28 +11,28 @@ export default function ChatNavIcon() {
|
||||||
const [newMessage, setNewMessage] = useState<string | null>(null);
|
const [newMessage, setNewMessage] = useState<string | null>(null);
|
||||||
const {start} = useTimeout(() => setNewMessage(null), 5000);
|
const {start} = useTimeout(() => setNewMessage(null), 5000);
|
||||||
|
|
||||||
const {user, pb, useSubscription} = usePB()
|
const {user, useSubscription} = usePB()
|
||||||
|
|
||||||
const match = useMatch("/chat/:threadId")
|
const match = useMatch("/chat/:listId")
|
||||||
|
|
||||||
useSubscription<MessagesModel>({
|
useSubscription<MessagesModel>({
|
||||||
idOrName: "messages",
|
idOrName: "messages",
|
||||||
topic: "*",
|
topic: "*",
|
||||||
callback: (event) => {
|
callback: (event) => {
|
||||||
if (event.action == "create" && event.record.thread) {
|
if (event.action == "create") {
|
||||||
pb.collection("messageThreads").getOne(event.record.thread).then(thread => {
|
if (event.record.isAnnouncement) {
|
||||||
if (thread.systemThread) {
|
|
||||||
start()
|
start()
|
||||||
setNewMessage("announcements")
|
setNewMessage("announcements")
|
||||||
} else if (
|
} else if (
|
||||||
match?.params.threadId !== event.record.thread // check if thread is not already open
|
event.record.eventList
|
||||||
|
&&
|
||||||
|
match?.params.listId !== event.record.eventList // check if chat page is not already open
|
||||||
&&
|
&&
|
||||||
event.record.sender !== user?.id // check if sender is not the user
|
event.record.sender !== user?.id // check if sender is not the user
|
||||||
) {
|
) {
|
||||||
start()
|
start()
|
||||||
setNewMessage(event.record.thread)
|
setNewMessage(event.record.eventList)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,26 +1,26 @@
|
||||||
import {MessagesModel, MessageThreadsModel} from "@/models/MessageTypes.ts";
|
import {MessagesModel} from "@/models/MessageTypes.ts";
|
||||||
import TextEditor from "@/components/input/Editor";
|
import TextEditor from "@/components/input/Editor";
|
||||||
import {useForm} from "@mantine/form";
|
import {useForm} from "@mantine/form";
|
||||||
import {ActionIcon, Button, Center, Group, Text} from "@mantine/core";
|
import {ActionIcon, Button, Center, Group, Text} from "@mantine/core";
|
||||||
import {IconMessageCircleUp, IconSend} from "@tabler/icons-react";
|
import {IconMessageCircleUp, IconSend} from "@tabler/icons-react";
|
||||||
|
|
||||||
import classes from './Messages.module.css'
|
import classes from './Messages.module.css'
|
||||||
import {useInfiniteQuery, useMutation} from "@tanstack/react-query";
|
import {useInfiniteQuery, useMutation} from "@tanstack/react-query";
|
||||||
import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx";
|
import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx";
|
||||||
import InnerHtml from "@/components/InnerHtml";
|
import InnerHtml from "@/components/InnerHtml";
|
||||||
import {getUserName} from "@/components/users/modals/util.tsx";
|
import {getUserName} from "@/components/users/modals/util.tsx";
|
||||||
import {pprintDateTime} from "@/lib/datetime.ts";
|
import {pprintDateTime} from "@/lib/datetime.ts";
|
||||||
|
import {EventListModel} from "@/models/EventTypes.ts";
|
||||||
|
|
||||||
export default function Messages({thread}: {
|
export default function Messages({eventList}: {
|
||||||
thread: MessageThreadsModel
|
eventList: EventListModel
|
||||||
}) {
|
}) {
|
||||||
const {user, pb, useSubscription} = usePB()
|
const {user, pb, useSubscription} = usePB()
|
||||||
|
|
||||||
const query = useInfiniteQuery({
|
const query = useInfiniteQuery({
|
||||||
queryKey: ["messages", thread],
|
queryKey: ["messages", eventList],
|
||||||
queryFn: async ({pageParam}) => (
|
queryFn: async ({pageParam}) => (
|
||||||
await pb.collection("messages").getList(pageParam, 100, {
|
await pb.collection("messages").getList(pageParam, 100, {
|
||||||
filter: `thread='${thread?.id}'`,
|
filter: `eventList='${eventList?.id}'`,
|
||||||
sort: "-created",
|
sort: "-created",
|
||||||
expand: "sender"
|
expand: "sender"
|
||||||
})
|
})
|
||||||
|
@ -35,9 +35,8 @@ export default function Messages({thread}: {
|
||||||
idOrName: "messages",
|
idOrName: "messages",
|
||||||
topic: "*",
|
topic: "*",
|
||||||
callback: (event) => {
|
callback: (event) => {
|
||||||
if (event.action == "create" && event.record.thread == thread.id) {
|
if (event.action == "create" && event.record.eventList == eventList.id) {
|
||||||
query.refetch()
|
query.refetch()
|
||||||
console.log("test")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -47,7 +46,7 @@ export default function Messages({thread}: {
|
||||||
await pb.collection("messages").create({
|
await pb.collection("messages").create({
|
||||||
...formValues.values,
|
...formValues.values,
|
||||||
sender: user!.id,
|
sender: user!.id,
|
||||||
thread: thread.id,
|
eventList: eventList.id,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|
|
@ -1,76 +0,0 @@
|
||||||
import {MessageThreadsModel} from "@/models/MessageTypes.ts";
|
|
||||||
import {usePB} from "@/lib/pocketbase.tsx";
|
|
||||||
import {useForm} from "@mantine/form";
|
|
||||||
import {useMutation} from "@tanstack/react-query";
|
|
||||||
import {ActionIcon, Box, Button, Group, TextInput} from "@mantine/core";
|
|
||||||
import UserInput from "@/components/users/UserInput.tsx";
|
|
||||||
import {IconX} from "@tabler/icons-react";
|
|
||||||
|
|
||||||
export default function UpsertThreadForm({thread, onSuccess, onCancel}: {
|
|
||||||
thread?: MessageThreadsModel,
|
|
||||||
onSuccess: () => void,
|
|
||||||
onCancel: () => void
|
|
||||||
}) {
|
|
||||||
const {user, pb} = usePB()
|
|
||||||
const formValues = useForm({
|
|
||||||
initialValues: {
|
|
||||||
name: thread?.name || "",
|
|
||||||
description: thread?.description || "",
|
|
||||||
participants: thread?.expand.participants || [user!],
|
|
||||||
},
|
|
||||||
validate: {
|
|
||||||
name: (value) => {
|
|
||||||
if (value.length < 1) {
|
|
||||||
return "Bitte gib einen Namen ein"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const mutation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
const values = {
|
|
||||||
name: formValues.values.name,
|
|
||||||
description: formValues.values.description,
|
|
||||||
participants: formValues.values.participants.map(p => p.id),
|
|
||||||
}
|
|
||||||
|
|
||||||
if (thread) {
|
|
||||||
await pb.collection("messageThreads").update(thread.id, values)
|
|
||||||
} else {
|
|
||||||
await pb.collection("messageThreads").create(values)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
onSuccess()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return <form onSubmit={formValues.onSubmit(() => mutation.mutate())}>
|
|
||||||
<TextInput
|
|
||||||
mb={"sm"}
|
|
||||||
placeholder={"Name"}
|
|
||||||
{...formValues.getInputProps("name")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Box mb={"sm"}>
|
|
||||||
<UserInput
|
|
||||||
placeholder={"Teilnehmer"}
|
|
||||||
selectedRecords={formValues.values.participants}
|
|
||||||
setSelectedRecords={(value) => formValues.setFieldValue("participants", value)}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Group>
|
|
||||||
<ActionIcon variant={"transparent"} size={"sm"} aria-label={"abort"} onClick={onCancel}>
|
|
||||||
<IconX/>
|
|
||||||
</ActionIcon>
|
|
||||||
<Button
|
|
||||||
size={"xs"}
|
|
||||||
variant={"light"}
|
|
||||||
type={"submit"}
|
|
||||||
loading={mutation.isPending}
|
|
||||||
>
|
|
||||||
Speichern
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</form>
|
|
||||||
}
|
|
|
@ -19,7 +19,7 @@ export default function EventOverview() {
|
||||||
<Breadcrumbs>{
|
<Breadcrumbs>{
|
||||||
[{title: "Home", to: "/"}, {title: "Events", to: "/events"},
|
[{title: "Home", to: "/"}, {title: "Events", to: "/events"},
|
||||||
].map(({title, to}) => (
|
].map(({title, to}) => (
|
||||||
<Anchor component={Link} to={to} key={title}>{title}</Anchor>
|
<Anchor component={Link} to={to} key={title} className={"wrapWords"}>{title}</Anchor>
|
||||||
))
|
))
|
||||||
}</Breadcrumbs>
|
}</Breadcrumbs>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import QRCodeModal from "@/pages/events/StatusEditor/QrCodeModal.tsx";
|
import QRCodeModal from "@/pages/events/StatusEditor/QrCodeModal.tsx";
|
||||||
import EntrySearchModal from "@/pages/events/StatusEditor/EntrySearchModal.tsx";
|
import EntrySearchModal from "@/pages/events/StatusEditor/EntrySearchModal.tsx";
|
||||||
import {ActionIcon, Center, Group, Text} from "@mantine/core";
|
import {ActionIcon, Group, Text, Title} from "@mantine/core";
|
||||||
import {IconQrcode, IconUserSearch} from "@tabler/icons-react";
|
import {IconQrcode, IconUserSearch} from "@tabler/icons-react";
|
||||||
import {useDisclosure} from "@mantine/hooks";
|
import {useDisclosure} from "@mantine/hooks";
|
||||||
import SearchSVG from "@/illustrations/search.svg?react"
|
import SearchSVG from "@/illustrations/search.svg?react"
|
||||||
|
@ -14,11 +14,13 @@ export default function EntrySearch() {
|
||||||
<QRCodeModal opened={showQrCodeModal} onClose={showQrCodeModalHandler.close}/>
|
<QRCodeModal opened={showQrCodeModal} onClose={showQrCodeModalHandler.close}/>
|
||||||
<EntrySearchModal opened={showEntrySearchModal} onClose={showEntrySearchModalHandler.close}/>
|
<EntrySearchModal opened={showEntrySearchModal} onClose={showEntrySearchModalHandler.close}/>
|
||||||
|
|
||||||
<Center>
|
<div className={"stack center"}>
|
||||||
<SearchSVG height={"300px"} width={"300px"}/>
|
<SearchSVG height={"300px"} width={"300px"}/>
|
||||||
</Center>
|
|
||||||
|
|
||||||
<div className={" center"}>
|
<Title c={"blue"} fw={900} mb={"xl"}>
|
||||||
|
Status Editor
|
||||||
|
</Title>
|
||||||
|
|
||||||
<Group justify={"center"}>
|
<Group justify={"center"}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="light" size="xl" radius="xl"
|
variant="light" size="xl" radius="xl"
|
||||||
|
|
|
@ -43,7 +43,7 @@ export default function StatusEditor() {
|
||||||
{title: event.name, to: `/events/${event.id}`},
|
{title: event.name, to: `/events/${event.id}`},
|
||||||
{title: "Status Editor", to: `/events/e/${event.id}/lists/status`},
|
{title: "Status Editor", to: `/events/e/${event.id}/lists/status`},
|
||||||
].map(({title, to}) => (
|
].map(({title, to}) => (
|
||||||
<Anchor component={Link} to={to} key={title}>
|
<Anchor component={Link} to={to} key={title} className={"wrapWords"}>
|
||||||
{title}
|
{title}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
))}</Breadcrumbs>
|
))}</Breadcrumbs>
|
||||||
|
|
|
@ -118,7 +118,7 @@ export default function EditEventRouter() {
|
||||||
{title: "Events", to: "/events"},
|
{title: "Events", to: "/events"},
|
||||||
{title: event.name, to: `/events/${event.id}`},
|
{title: event.name, to: `/events/${event.id}`},
|
||||||
].map(({title, to}) => (
|
].map(({title, to}) => (
|
||||||
<Anchor component={Link} to={to} key={title}>
|
<Anchor className={"wrapWords"} component={Link} to={to} key={title}>
|
||||||
{title}
|
{title}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
))}</Breadcrumbs>
|
))}</Breadcrumbs>
|
||||||
|
|
|
@ -6,6 +6,7 @@ import {
|
||||||
IconForms,
|
IconForms,
|
||||||
IconLock,
|
IconLock,
|
||||||
IconLockOpen,
|
IconLockOpen,
|
||||||
|
IconMessageCircle,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconUserCog
|
IconUserCog
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
@ -57,6 +58,14 @@ 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(...[
|
||||||
{
|
{
|
||||||
|
@ -81,10 +90,10 @@ export default function EventListRouter({event}: { event: EventModel }) {
|
||||||
<Breadcrumbs>
|
<Breadcrumbs>
|
||||||
<Link to={`/events/e/${event.id}/lists/overview`}>
|
<Link to={`/events/e/${event.id}/lists/overview`}>
|
||||||
<Title order={2}>
|
<Title order={2}>
|
||||||
Event Listen
|
Listen
|
||||||
</Title>
|
</Title>
|
||||||
</Link>
|
</Link>
|
||||||
<Link to={`/events/e/${event.id}/lists/overview/${list.id}`}>
|
<Link to={`/events/e/${event.id}/lists/overview/${list.id}`} className={"wrapWords"}>
|
||||||
<Title order={2}>
|
<Title order={2}>
|
||||||
{list.name}
|
{list.name}
|
||||||
</Title>
|
</Title>
|
||||||
|
|
|
@ -21,6 +21,7 @@ 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 || "",
|
||||||
}
|
}
|
||||||
|
@ -105,6 +106,19 @@ 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
|
||||||
|
@ -113,7 +127,6 @@ export default function ListSettings({list, event}: { list: EventListModel, even
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Group>
|
<Group>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={"outline"}
|
variant={"outline"}
|
||||||
color={"red"}
|
color={"red"}
|
||||||
|
|
|
@ -5,7 +5,6 @@ import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Alert,
|
Alert,
|
||||||
Autocomplete,
|
Autocomplete,
|
||||||
Box,
|
|
||||||
Button,
|
Button,
|
||||||
Center,
|
Center,
|
||||||
Collapse,
|
Collapse,
|
||||||
|
@ -22,9 +21,9 @@ import {
|
||||||
import {hasLength, useForm} from "@mantine/form";
|
import {hasLength, useForm} from "@mantine/form";
|
||||||
import TextEditor from "@/components/input/Editor";
|
import TextEditor from "@/components/input/Editor";
|
||||||
import {Link, useNavigate} from "react-router-dom";
|
import {Link, useNavigate} from "react-router-dom";
|
||||||
import {IconArrowRight, IconListSearch, IconLock, IconLockOpen, IconPlus} from "@tabler/icons-react";
|
import {IconArrowRight, IconListSearch, IconLock, IconLockOpen, IconMinus, IconPlus} from "@tabler/icons-react";
|
||||||
import {useRef, useState} from "react";
|
import {useState} from "react";
|
||||||
import {useDebouncedState} from "@mantine/hooks";
|
import {useDebouncedState, useDisclosure} from "@mantine/hooks";
|
||||||
import {onlyUnique} from "@/lib/util.ts";
|
import {onlyUnique} from "@/lib/util.ts";
|
||||||
import {useEventRights} from "@/pages/events/util.ts";
|
import {useEventRights} from "@/pages/events/util.ts";
|
||||||
|
|
||||||
|
@ -88,6 +87,8 @@ export default function EventListsOverview({event}: { event: EventModel }) {
|
||||||
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [createNewList, createNewListHandler] = useDisclosure(false)
|
||||||
|
|
||||||
const formValues = useForm({
|
const formValues = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
name: "",
|
name: "",
|
||||||
|
@ -123,48 +124,43 @@ export default function EventListsOverview({event}: { event: EventModel }) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const newListNameRef = useRef<HTMLInputElement>(null)
|
|
||||||
|
|
||||||
const {canEditEvent} = useEventRights(event)
|
const {canEditEvent} = useEventRights(event)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={"stack"}>
|
<div className={"stack"}>
|
||||||
{canEditEvent &&
|
{canEditEvent &&
|
||||||
|
<Collapse in={createNewList}>
|
||||||
<form
|
<form
|
||||||
className={"section stack"}
|
className={"section stack"}
|
||||||
onSubmit={formValues.onSubmit(() => createListMutation.mutate())}
|
onSubmit={formValues.onSubmit(() => createListMutation.mutate())}
|
||||||
>
|
>
|
||||||
<Title order={3} c={"blue"}>
|
<Title order={3} c={"blue"}>
|
||||||
<Group>
|
|
||||||
<ActionIcon
|
|
||||||
mb={"5"} variant={"transparent"}
|
|
||||||
onClick={() => newListNameRef.current?.focus()}
|
|
||||||
>
|
|
||||||
<IconPlus/>
|
|
||||||
</ActionIcon>
|
|
||||||
Neue Liste Erstellen
|
Neue Liste Erstellen
|
||||||
</Group>
|
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
<PocketBaseErrorAlert error={createListMutation.error}/>
|
<PocketBaseErrorAlert error={createListMutation.error}/>
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
ref={newListNameRef}
|
|
||||||
variant={"filled"}
|
|
||||||
placeholder={"Name der neuen Liste ..."}
|
placeholder={"Name der neuen Liste ..."}
|
||||||
{...formValues.getInputProps("name")}
|
{...formValues.getInputProps("name")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Collapse in={!!formValues.values.name}>
|
|
||||||
<Title order={4} c={"blue"}>Beschreibung</Title>
|
<Title order={4} c={"blue"}>Beschreibung</Title>
|
||||||
|
|
||||||
<TextEditor
|
<TextEditor
|
||||||
mt={"sm"}
|
|
||||||
value={formValues.values.description}
|
value={formValues.values.description}
|
||||||
onChange={(value) => formValues.setFieldValue("description", value)}
|
onChange={(value) => formValues.setFieldValue("description", value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Box mt={"sm"}>
|
<Group>
|
||||||
|
<Button
|
||||||
|
variant={"light"}
|
||||||
|
color={"orange"}
|
||||||
|
onClick={createNewListHandler.close}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type={"submit"}
|
type={"submit"}
|
||||||
loading={createListMutation.isPending}
|
loading={createListMutation.isPending}
|
||||||
|
@ -172,15 +168,25 @@ export default function EventListsOverview({event}: { event: EventModel }) {
|
||||||
>
|
>
|
||||||
Liste Erstellen
|
Liste Erstellen
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Group>
|
||||||
</Collapse>
|
|
||||||
</form>
|
</form>
|
||||||
|
</Collapse>
|
||||||
}
|
}
|
||||||
|
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
placeholder={"Nach einer Liste suchen ..."}
|
placeholder={"Nach einer Liste suchen ..."}
|
||||||
leftSection={<IconListSearch/>}
|
leftSection={<IconListSearch/>}
|
||||||
rightSection={listsQuery.isLoading ? <Loader size={"xs"}/> : undefined}
|
rightSection={listsQuery.isLoading ? <Loader size={"xs"}/> : (
|
||||||
|
canEditEvent &&
|
||||||
|
<Tooltip label={"Neue Liste erstellen"} withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
variant={"transparent"}
|
||||||
|
onClick={createNewListHandler.toggle}
|
||||||
|
>
|
||||||
|
{createNewList ? <IconMinus/> : <IconPlus/>}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
data={
|
data={
|
||||||
listsQuery
|
listsQuery
|
||||||
.data
|
.data
|
||||||
|
|
|
@ -67,7 +67,7 @@ export default function UserEntries() {
|
||||||
{title: "Events", to: "/events"},
|
{title: "Events", to: "/events"},
|
||||||
{title: "Anmeldungen", to: `/events/entries`},
|
{title: "Anmeldungen", to: `/events/entries`},
|
||||||
].map(({title, to}) => (
|
].map(({title, to}) => (
|
||||||
<Anchor component={Link} to={to} key={title}>
|
<Anchor component={Link} to={to} key={title} className={"wrapWords"}>
|
||||||
{title}
|
{title}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
))}</Breadcrumbs>
|
))}</Breadcrumbs>
|
||||||
|
|
|
@ -23,7 +23,7 @@ export default function EventListView({event, listId}: { event: EventModel, list
|
||||||
|
|
||||||
const listSlotsQuery = useQuery({
|
const listSlotsQuery = useQuery({
|
||||||
queryKey: ["event", event.id, "list", listId, "slots", activePage],
|
queryKey: ["event", event.id, "list", listId, "slots", activePage],
|
||||||
queryFn: async () => (await pb.collection("eventListSlotsWithEntriesCount").getList(activePage, 10, {
|
queryFn: async () => (await pb.collection("eventListSlotsWithEntriesCount").getList(activePage, 20, {
|
||||||
filter: `eventList='${listId}'`,
|
filter: `eventList='${listId}'`,
|
||||||
sort: "startDate",
|
sort: "startDate",
|
||||||
enabled: !!listId,
|
enabled: !!listId,
|
||||||
|
|
|
@ -66,7 +66,7 @@ export default function SharedEvent() {
|
||||||
{title: "Events", to: "/events"},
|
{title: "Events", to: "/events"},
|
||||||
{title: event.name, to: `/events/${event.id}`},
|
{title: event.name, to: `/events/${event.id}`},
|
||||||
].map(({title, to}) => (
|
].map(({title, to}) => (
|
||||||
<Anchor component={Link} to={to} key={title}>
|
<Anchor component={Link} to={to} key={title} className={"wrapWords"}>
|
||||||
{title}
|
{title}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
))}</Breadcrumbs>
|
))}</Breadcrumbs>
|
||||||
|
|
|
@ -67,12 +67,10 @@
|
||||||
|
|
||||||
a {
|
a {
|
||||||
text-decoration: unset; /* Removes default underline */
|
text-decoration: unset; /* Removes default underline */
|
||||||
color: revert;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a:hover, a:active, a:visited {
|
a:hover, a:active, a:visited {
|
||||||
text-decoration: unset; /* Removes default underline */
|
text-decoration: unset; /* Removes default underline */
|
||||||
color: unset;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-icon {
|
.section-icon {
|
||||||
|
@ -156,5 +154,5 @@ a:hover, a:active, a:visited {
|
||||||
}
|
}
|
||||||
|
|
||||||
.monospace {
|
.monospace {
|
||||||
font-family: var(--mantine-font-family-monospace);
|
font-family: var(--mantine-font-family-monospace), monospace;
|
||||||
}
|
}
|
Loading…
Reference in New Issue