feat(chat): fixed issue for mobile and added chat
Build and Push Docker image / build-and-push (push) Successful in 4m30s Details

This commit is contained in:
Valentin Kolb 2024-05-22 17:09:00 +02:00
parent 03bd913d45
commit 4f53566b61
76 changed files with 1847 additions and 323 deletions

View File

@ -9,5 +9,5 @@ export const PB_STORAGE_KEY = "stuve-it-login-record"
// general
export const APP_NAME = "StuVe IT"
export const APP_VERSION = "0.5.0 (beta)"
export const APP_VERSION = "0.7.0 (beta)"
export const APP_URL = "https://it.stuve.uni-ulm.de"

View File

@ -27,11 +27,13 @@
"@tiptap/extension-collaboration": "^2.3.0",
"@tiptap/extension-collaboration-cursor": "^2.3.0",
"@tiptap/extension-link": "^2.3.0",
"@tiptap/extension-mention": "^2.4.0",
"@tiptap/extension-placeholder": "^2.3.0",
"@tiptap/extension-underline": "^2.3.0",
"@tiptap/pm": "^2.3.0",
"@tiptap/react": "^2.3.0",
"@tiptap/starter-kit": "^2.3.0",
"@tiptap/suggestion": "^2.4.0",
"@types/react-big-calendar": "^1.8.9",
"clsx": "^2.1.1",
"dayjs": "^1.11.10",
@ -43,11 +45,14 @@
"react-big-calendar": "^1.11.3",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-infinite-scroll-component": "^6.1.0",
"react-intersection-observer": "^9.10.2",
"react-markdown": "^9.0.1",
"react-router-dom": "^6.20.0",
"recoil": "^0.7.7",
"remark-gfm": "^4.0.0",
"sanitize-html": "^2.13.0",
"tippy.js": "^6.3.7",
"tiptap": "^1.32.2",
"zod": "^3.22.4"
},

View File

@ -5,6 +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";
const router = createBrowserRouter([
{
@ -23,6 +25,14 @@ const router = createBrowserRouter([
path: "events/*",
element: <EventsRouter/>,
},
{
path: "chat/*",
element: <ChatRouter/>,
},
{
path: "debug",
element: <DebugPage/>
},
{
path: "util",
children: [

View File

@ -0,0 +1,31 @@
import {rem, Switch, useMantineColorScheme, useMantineTheme} from "@mantine/core";
import {IconMoonStars, IconSun} from "@tabler/icons-react";
export default function ColorSchemeSwitch() {
const {colorScheme, toggleColorScheme} = useMantineColorScheme()
const theme = useMantineTheme()
const sunIcon = (
<IconSun
style={{width: rem(16), height: rem(16)}}
stroke={2.5}
color={theme.colors.yellow[4]}
/>
)
const moonIcon = (
<IconMoonStars
style={{width: rem(16), height: rem(16)}}
stroke={2.5}
color={theme.colors.blue[6]}
/>
)
return <Switch
color="dark.4"
checked={colorScheme === "dark"}
onChange={toggleColorScheme}
onLabel={sunIcon} offLabel={moonIcon}
label={"Farbschema umschalten"}
/>
}

View File

@ -21,7 +21,6 @@
overflow: auto;
}
.toolbar {
display: flex;
flex-direction: row;

View File

@ -1,4 +1,4 @@
import {BubbleMenu, Editor, useEditor} from "@tiptap/react";
import {BubbleMenu, Editor, Extension, useEditor} from "@tiptap/react";
import {StarterKit} from "@tiptap/starter-kit";
import {Underline} from "@tiptap/extension-underline";
import Placeholder from '@tiptap/extension-placeholder';
@ -14,6 +14,9 @@ const Bubble = ({editor}: { editor: Editor }) => (
<RichTextEditor.ControlsGroup>
<RichTextEditor.Bold/>
<RichTextEditor.Italic/>
<RichTextEditor.BulletList/>
<RichTextEditor.Link/>
<RichTextEditor.ClearFormatting/>
</RichTextEditor.ControlsGroup>
</BubbleMenu>
)
@ -29,6 +32,7 @@ const Toolbar = ({fullToolbar}: { fullToolbar: boolean, editor: Editor }) => (
<RichTextEditor.Underline/>
<RichTextEditor.Code/>
<RichTextEditor.Strikethrough/>
<RichTextEditor.ClearFormatting/>
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup>
@ -88,6 +92,7 @@ export default function TextEditor({
hideToolbar,
noBorder,
disabled,
modEnter,
...props
}: {
value: string;
@ -98,6 +103,7 @@ export default function TextEditor({
hideToolbar?: boolean;
noBorder?: boolean;
disabled?: boolean;
modEnter?: () => void;
} & Omit<InputWrapperProps, "onChange">) {
const editor = useEditor({
@ -105,7 +111,17 @@ export default function TextEditor({
StarterKit,
Underline,
Link,
Placeholder.configure({placeholder: placeholder})
Placeholder.configure({placeholder: placeholder}),
Extension.create({
addKeyboardShortcuts() {
return {
'Mod-Enter': () => {
modEnter?.()
return modEnter !== undefined
},
}
},
})
],
editable: !disabled,
content: value,

View File

@ -3,6 +3,38 @@
display: flex;
flex-direction: column;
gap: var(--gap);
z-index: 1000;
@media (max-width: $mantine-breakpoint-sm) {
padding: calc(var(--padding)/2) var(--mantine-spacing-xl);
}
/*noinspection CssInvalidFunction*/
//box-shadow: 0 0 5px 10px light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-9));;
}
.bottomContainer {
display: flex;
flex-direction: row;
gap: var(--gap);
color: var(--mantine-color-dimmed);
& > :last-child {
text-decoration: underline;
}
}
.bottomText {
font-size: var(--mantine-font-size-sm);
}
.underline {
cursor: pointer;
text-decoration: underline;
&:hover {
text-decoration: none;
}
}
.logo {
@ -10,12 +42,17 @@
flex-direction: row;
gap: var(--gap);
justify-content: center;
align-items: center;
@media (min-width: $mantine-breakpoint-sm) {
display: none !important;
}
}
.title {
color: var(--mantine-color-text);
font-size: var(--mantine-font-size-lg);
font-weight: 600;
color: var(--mantine-color-dimmed);
font-size: var(--mantine-font-size-xs);
font-weight: 500;
}
@ -60,13 +97,3 @@
}
}
.rights {
color: var(--mantine-color-dimmed);
font-size: var(--mantine-font-size-sm);
& > span {
[data-apiishealthy="true"] {
color: var(--mantine-color-red-filled);
}
}
}

View File

@ -1,72 +1,81 @@
import classes from "./index.module.css";
import {APP_NAME, APP_VERSION} from "../../../../config.ts";
import {Anchor, Divider, Image} from "@mantine/core";
import {Link} from "react-router-dom";
import {Menu, UnstyledButton, useMantineColorScheme} from "@mantine/core";
import {NavLink} from "react-router-dom";
import {usePB} from "@/lib/pocketbase.tsx";
import {IconSectionSign} from "@tabler/icons-react";
import StuVeLogo from "~/stuve-logo.svg?react";
export default function Footer() {
const {apiIsHealthy} = usePB()
const {colorScheme, toggleColorScheme} = useMantineColorScheme()
const currentYear = new Date().getFullYear()
return (
<div className={classes.footer}>
<div className={classes.inner}>
<div className={classes.logo}>
<Image
h={15}
w={15}
src={"/stuve-logo.svg"}
alt={"StuVe IT Logo"}
/>
<div className={classes.title}>{APP_NAME}</div>
</div>
<div className={`${classes.links} ${classes.hideMobile}`}>
<h5>Über</h5>
<p>Version {APP_VERSION}</p>
<p>Entwickelt vom StuVe Computer Referat</p>
<Anchor href={"https://git.stuve.uni-ulm.de/stuve-it/stuve-it-frontend"}>Source Code</Anchor>
<Anchor href={"https://git.stuve.uni-ulm.de/stuve-it/stuve-it-frontend/issues"}>Issues</Anchor>
</div>
<div className={`${classes.links} ${classes.hideMobile}`}>
<h5>Made with</h5>
<Anchor href={"https://mantine.dev/"}>Mantine</Anchor>
<Anchor href={"https://tanstack.com/"}>React Query</Anchor>
<Anchor href={"https://pocketbase.dev/"}>PocketBase</Anchor>
<Anchor
href={"https://git.stuve.uni-ulm.de/stuve-it/stuve-it-frontend/src/branch/main/package.json"}>...
and much more</Anchor>
</div>
{/* only these links will show on mobile */}
<div className={classes.links}>
<h5 className={classes.hideMobile}>Rechtliches</h5>
<Link to={"/legal/terms-and-conditions"}>AGB der StuVe</Link>
<Link to={"/legal/privacy-policy"}>Datenschutzerklärung</Link>
<Link to={"/legal/imprint"}>Impressum</Link>
<footer className={`${classes.footer}`}>
<div className={classes.logo}>
<StuVeLogo
height={15}
width={15}
/>
<div className={classes.title}>
© {currentYear} {APP_NAME} - {APP_VERSION}
</div>
</div>
<Divider/>
<p className={classes.rights}>© 2024 {APP_NAME}. All rights reserved.
{" "}
<span data-apiishealthy={apiIsHealthy}>
{apiIsHealthy ? "Das Backend ist erreichbar." : "Das Backend ist nicht erreichbar!"}
</span>
</p>
</div>
<div className={`${classes.bottomContainer} ${classes.hideMobile}`}>
<div className={classes.bottomText}>{APP_VERSION}</div>
<div className={classes.bottomText}>© {currentYear} {APP_NAME}</div>
<div className={classes.bottomText}>
{apiIsHealthy ? "Backend erreichbar" : "Backend nicht erreichbar"}
</div>
<UnstyledButton className={`${classes.bottomText} ${classes.underline}`} component={"span"}
onClick={toggleColorScheme}>
{colorScheme === "dark" ? "Heller Modus" : "Dunkler Modus"}
</UnstyledButton>
<UnstyledButton
component={"a"}
className={`${classes.bottomText} ${classes.underline}`}
href={"https://git.stuve.uni-ulm.de/stuve-it/stuve-it-frontend"}
>
Source Code
</UnstyledButton>
<Menu trigger={"click-hover"}>
<Menu.Target>
<div className={`${classes.bottomText} ${classes.underline}`}>
Rechtliches
</div>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconSectionSign size={16}/>}
component={NavLink}
to={"/legal/imprint"}
>
Impressum
</Menu.Item>
<Menu.Item
leftSection={<IconSectionSign size={16}/>}
component={NavLink}
to={"/legal/privacy-policy"}
>
Datenschutzerklärung
</Menu.Item>
<Menu.Item
leftSection={<IconSectionSign size={16}/>}
component={NavLink}
to={"/legal/terms-and-conditions"}
>
AGB
</Menu.Item>
</Menu.Dropdown>
</Menu>
</div>
</footer>
)
}

View File

@ -10,16 +10,18 @@
}
.body {
overflow: auto;
overflow: hidden;
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 0 var(--gap);
}
.content {
position: relative;
overflow: scroll;
padding: 0 var(--gap);
padding-top: var(--gap);
& > * {

View File

@ -3,12 +3,13 @@ import {Outlet} from "react-router-dom";
import classes from "./index.module.css";
import Footer from "./footer";
import LoginModal from "@/components/auth/modals/LoginModal.tsx";
import UserMenuModal from "@/components/auth/modals/UserMenuModal.tsx";
import RegisterModal from "@/components/auth/modals/RegisterModal.tsx";
import EmailTokenVerification from "@/components/auth/modals/EmailTokenVerification.tsx";
import ForgotPasswordModal from "@/components/auth/modals/ForgotPasswordModal.tsx";
import ChangeEmailModal from "@/components/auth/modals/ChangeEmailModal.tsx";
import LoginModal from "@/components/users/modals/LoginModal.tsx";
import UserMenuModal from "@/components/users/modals/UserMenuModal.tsx";
import RegisterModal from "@/components/users/modals/RegisterModal.tsx";
import EmailTokenVerification from "@/components/users/modals/EmailTokenVerification.tsx";
import ForgotPasswordModal from "@/components/users/modals/ForgotPasswordModal.tsx";
import ChangeEmailModal from "@/components/users/modals/ChangeEmailModal.tsx";
import ShowMessagesModal from "@/components/users/modals/ShowMessagesModal";
export default function Layout({hideNav}: { hideNav?: boolean }) {
return <div className={classes.container}>
@ -20,9 +21,10 @@ export default function Layout({hideNav}: { hideNav?: boolean }) {
<EmailTokenVerification/>
<ForgotPasswordModal/>
<ChangeEmailModal/>
<ShowMessagesModal/>
<div className={`${classes.body} no-scrollbar`}>
<div className={`${classes.content}`}>
<div className={`${classes.body}`}>
<div className={`${classes.content} no-scrollbar`}>
<Outlet/>
</div>
<Footer/>

View File

@ -1,7 +1,17 @@
import {Menu} from "@mantine/core";
import {NavLink} from "react-router-dom";
import {Fragment} from "react";
import {IconConfetti, IconHome, IconList, IconQrcode} from "@tabler/icons-react";
import {
IconBug,
IconConfetti,
IconHome,
IconList,
IconMessageCircle,
IconQrcode,
IconSectionSign,
IconSpeakerphone
} from "@tabler/icons-react";
import {useShowDebug} from "@/components/ShowDebug.tsx";
const NavItems = [
@ -14,6 +24,18 @@ const NavItems = [
description: "Home",
link: "/"
},
{
title: "Nachrichten",
icon: IconMessageCircle,
description: "Nachrichten",
link: "/chat"
},
{
title: "Ankündigungen",
icon: IconSpeakerphone,
description: "Ankündigungen",
link: "/chat/announcements"
}
]
},
{
@ -31,7 +53,7 @@ const NavItems = [
icon: IconList,
description: "Deine Anmeldungen bei Events.",
link: "/events/entries"
},
}
]
},
{
@ -45,11 +67,61 @@ const NavItems = [
}
]
},
{
section: "Rechtliches",
items: [
{
title: "Impressum",
icon: IconSectionSign,
description: "Impressum",
link: "/legal/imprint"
},
{
title: "Datenschutzerklärung",
icon: IconSectionSign,
description: "Datenschutzerklärung",
link: "/legal/privacy-policy"
},
{
title: "AGB",
icon: IconSectionSign,
description: "AGB",
link: "/legal/terms-and-conditions"
}
]
}
]
const DebugMenuItems = [
{
section: "Debug",
items: [
{
title: "Debug Seite",
icon: IconBug,
description: "Debug",
link: "/debug",
color: "orange"
}
]
}
]
export default function MenuItems() {
const {showDebug} = useShowDebug()
let nav
if (showDebug) {
nav = [...NavItems, ...DebugMenuItems]
} else {
nav = NavItems
}
return <>
{NavItems.map((section, index) => (
{nav.map((section, index) => (
<Fragment key={index + section.section}>
<Menu.Label>
{section.section}
@ -63,6 +135,7 @@ export default function MenuItems() {
component={NavLink}
to={item.link}
aria-label={item.description}
color={'color' in item ? item.color as string : undefined}
>
{item.title}
</Menu.Item>

View File

@ -22,6 +22,16 @@
.title {
font-size: var(--mantine-h2-font-size);
font-weight: bold;
transition: font-size 0.3s ease-in-out;
@media (max-width: 768px) {
font-size: var(--mantine-h3-font-size);
}
@media (max-width: 576px) {
font-size: var(--mantine-h4-font-size);
}
}
.actionIcons {

View File

@ -1,16 +1,15 @@
import {usePB} from "@/lib/pocketbase.tsx";
import classes from "./index.module.css";
import {ActionIcon, Image, Menu, ThemeIcon, useMantineColorScheme} from "@mantine/core";
import {IconChevronDown, IconLogin, IconMoon, IconSun, IconUserStar} from "@tabler/icons-react";
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/auth/modals/hooks.ts";
import {useLogin, useUserMenu} from "@/components/users/modals/hooks.ts";
import ChatNavIcon from "@/pages/chat/components/ChatNavIcon.tsx";
export default function NavBar() {
const {user} = usePB()
const {colorScheme, toggleColorScheme} = useMantineColorScheme()
const {handler: userMenuHandler} = useUserMenu()
const {handler: loginHandler} = useLogin()
@ -46,27 +45,18 @@ export default function NavBar() {
</Menu>
<div className={classes.actionIcons}>
<ActionIcon
variant={"transparent"}
color={"gray"}
onClick={toggleColorScheme}
>
{colorScheme === "dark" ?
<IconSun/>
:
<IconMoon/>
}
</ActionIcon>
{user ?
<ActionIcon
variant={"transparent"}
color={"gray"}
aria-label={"User Menu"}
onClick={userMenuHandler.open}
>
<IconUserStar/>
</ActionIcon>
<>
<ChatNavIcon/>
<ActionIcon
variant={"transparent"}
color={"gray"}
aria-label={"User Menu"}
onClick={userMenuHandler.open}
>
<IconUserStar/>
</ActionIcon>
</>
: (
<ActionIcon
variant={"transparent"}

View File

@ -8,7 +8,7 @@ export default function UsersDisplay({users}: { users: UserModal[] }) {
const {user} = usePB()
return <>
<List size={"sm"} icon={<IconUser size={16}/>}>
<List size={"sm"} icon={<IconUser size={10}/>}>
{
users.map((u) => (
<List.Item key={u.id} fw={500} c={user && user.id == u.id ? "green" : ""}>

View File

@ -1,4 +1,4 @@
import {useChangeEmail} from "@/components/auth/modals/hooks.ts";
import {useChangeEmail} from "@/components/users/modals/hooks.ts";
import {isEmail, useForm} from "@mantine/form";
import {Button, Center, Group, Modal, PasswordInput, TextInput} from "@mantine/core";
import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx";
@ -8,7 +8,8 @@ import {showSuccessNotification} from "@/components/util.tsx";
import {useSearchParams} from "react-router-dom";
import EmailSVG from "@/illustrations/email.svg?react"
import PromptLoginModal from "@/components/auth/modals/PromptLoginModal.tsx";
import PromptLoginModal from "@/components/users/modals/PromptLoginModal.tsx";
import ShowHelp from "@/components/ShowHelp.tsx";
export const CHANGE_EMAIL_TOKEN_KEY = "changeEmailToken"
@ -48,6 +49,10 @@ const RequestEmailChangeModal = ({open, onClose}: {
<EmailSVG height={"200px"} width={"200px"}/>
</Center>
<ShowHelp>
Nur Gast Accounts können ihre E-Mail ändern.
</ShowHelp>
<PocketBaseErrorAlert error={mutation.error}/>
<TextInput
@ -117,6 +122,10 @@ const ConfirmEmailChangeModal = ({open, onClose, token}: {
<EmailSVG height={"200px"} width={"200px"}/>
</Center>
<ShowHelp>
Nur Gast Accounts können ihre E-Mail ändern.
</ShowHelp>
<PocketBaseErrorAlert error={mutation.error}/>
<PasswordInput

View File

@ -5,7 +5,7 @@ import {usePB} from "@/lib/pocketbase.tsx";
import {showErrorNotification, showSuccessNotification} from "@/components/util.tsx";
import {Alert, Button, Group, Modal, TextInput, Title} from "@mantine/core";
import {IconAt} from "@tabler/icons-react";
import {useLogin} from "@/components/auth/modals/hooks.ts";
import {useLogin} from "@/components/users/modals/hooks.ts";
export const EMAIL_TOKEN_KEY = "emailVerificationToken"

View File

@ -1,4 +1,4 @@
import {useForgotPassword, useLogin} from "@/components/auth/modals/hooks.ts";
import {useForgotPassword, useLogin} from "@/components/users/modals/hooks.ts";
import {isEmail, useForm} from "@mantine/form";
import {Button, Center, Collapse, Group, Modal, PasswordInput, TextInput} from "@mantine/core";
import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx";
@ -9,6 +9,7 @@ import {useSearchParams} from "react-router-dom";
import PasswordSVG from "@/illustrations/boy-with-key.svg?react";
import PasswordStrengthMeter, {getPasswordStrength} from "@/components/input/PasswordStrengthMeter.tsx";
import ShowHelp from "@/components/ShowHelp.tsx";
export const PWD_RESET_TOKEN_KEY = "passwordResetToken"
@ -47,6 +48,11 @@ const RequestResetPasswordModal = ({open, onClose}: {
<PasswordSVG height={"200px"} width={"200px"}/>
</Center>
<ShowHelp>
Nur Gast Accounts können ihr Passwort zurücksetzen.
Wenn du dein StuVe IT Account Passwort vergessen hast, wende dich bitte an das C-Ref.
</ShowHelp>
<PocketBaseErrorAlert error={requestResetPasswordMutation.error}/>
<TextInput
@ -126,6 +132,11 @@ const ResetPasswordModal = ({open, onClose, token}: {
<PasswordSVG height={"200px"} width={"200px"}/>
</Center>
<ShowHelp>
Nur Gast Accounts können ihr Passwort zurücksetzen.
Wenn du dein StuVe IT Account Passwort vergessen hast, wende dich bitte an das C-Ref.
</ShowHelp>
<PocketBaseErrorAlert error={requestResetPasswordMutation.error}/>
<PasswordInput

View File

@ -17,7 +17,7 @@ import {
Title
} from "@mantine/core";
import LoginSVG from "@/illustrations/boy-with-key.svg?react"
import {useForgotPassword, useLogin, useRegister} from "@/components/auth/modals/hooks.ts";
import {useForgotPassword, useLogin, useRegister} from "@/components/users/modals/hooks.ts";
import {showSuccessNotification} from "@/components/util.tsx";
@ -119,16 +119,19 @@ export default function LoginModal() {
<Divider label={"oder"}/>
<Group justify={"space-evenly"}>
<Button
size={"compact-xs"}
variant={"transparent"}
onClick={() => {
handler.close()
forgorPasswordHandler.open()
}}
>
Passwort vergessen?
</Button>
{
formValues.values.authMethod === "guest" &&
<Button
size={"compact-xs"}
variant={"transparent"}
onClick={() => {
handler.close()
forgorPasswordHandler.open()
}}
>
Passwort vergessen?
</Button>
}
<Button
size={"compact-xs"}

View File

@ -1,6 +1,6 @@
import {useDisclosure} from "@mantine/hooks";
import {Button, Group, Modal} from "@mantine/core";
import {useLogin} from "@/components/auth/modals/hooks.ts";
import {useLogin} from "@/components/users/modals/hooks.ts";
import {IconLogin, IconX} from "@tabler/icons-react";
export default function PromptLoginModal({onAbort, description}: {
@ -9,6 +9,7 @@ export default function PromptLoginModal({onAbort, description}: {
}) {
const [open, openHandler] = useDisclosure(true)
const {handler: loginHandler} = useLogin()
return <Modal opened={open} onClose={openHandler.close} title={"Zugang beschränkt"}>
<div className={"stack"}>
@ -18,7 +19,6 @@ export default function PromptLoginModal({onAbort, description}: {
</>
}
<Group>
<Button
variant={"light"}

View File

@ -1,4 +1,4 @@
import {useRegister} from "@/components/auth/modals/hooks.ts";
import {useRegister} from "@/components/users/modals/hooks.ts";
import {isEmail, useForm} from "@mantine/form";
import {Alert, Anchor, Button, Checkbox, Collapse, Group, Modal, PasswordInput, Text, TextInput} from "@mantine/core";
import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx";

View File

@ -0,0 +1,26 @@
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>
</>
}

View File

@ -0,0 +1,5 @@
.messagesContainer {
display: flex;
flex-direction: column-reverse;
gap: var(--gap);
}

View File

@ -0,0 +1,109 @@
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'}}>&#8595; Pull down to refresh</h3>
}
releaseToRefreshContent={
<h3 style={{textAlign: 'center'}}>&#8593; 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>
</>
}

View File

@ -1,8 +1,8 @@
import {useChangeEmail, useForgotPassword, useUserMenu} from "@/components/auth/modals/hooks.ts";
import {useChangeEmail, useForgotPassword, useUserMenu} from "@/components/users/modals/hooks.ts";
import {usePB} from "@/lib/pocketbase.tsx";
import {useShowHelp} from "@/components/ShowHelp.tsx";
import ShowDebug, {useShowDebug} from "@/components/ShowDebug.tsx";
import {ActionIcon, Code, Divider, Group, Modal, Switch, Text, ThemeIcon, Title, Tooltip} from "@mantine/core";
import {ActionIcon, Code, Divider, Group, Modal, Switch, Text, ThemeIcon, Title, Tooltip,} from "@mantine/core";
import classes from "@/components/layout/nav/index.module.css";
import {
IconAt,
@ -11,9 +11,11 @@ import {
IconMailCog,
IconPassword,
IconServer,
IconServerOff, IconUser
IconServerOff,
IconUser
} from "@tabler/icons-react";
import LdapGroupsDisplay from "@/components/auth/LdapGroupsDisplay.tsx";
import LdapGroupsDisplay from "@/components/users/LdapGroupsDisplay.tsx";
import ColorSchemeSwitch from "@/components/input/ColorSchemeSwitch.tsx";
export default function UserMenuModal() {
const {value, handler} = useUserMenu()
@ -55,9 +57,9 @@ export default function UserMenuModal() {
</ThemeIcon>
<Text>
{user?.REALM === "LDAP" ? "StuVe IT Account" : "Gast Account"}
</Text>
<Text>
{user?.REALM === "LDAP" ? "StuVe IT Account" : "Gast Account"}
</Text>
</div>
<div className={classes.row}>
@ -68,7 +70,6 @@ export default function UserMenuModal() {
<IconAt/>
</ThemeIcon>
<Tooltip label={`Dein Email ist ${user?.emailVisibility ? "sichtbar" : "nicht sichtbar"}`}>
<Text>
{user?.email}
@ -107,7 +108,6 @@ export default function UserMenuModal() {
>
<IconServer/>
</ThemeIcon>
) : (
<ThemeIcon
variant={"transparent"}
@ -134,6 +134,9 @@ export default function UserMenuModal() {
Die folgenden Einstellungen werden lokal auf deinem Gerät gespeichert und nicht an den Server
übertragen.
</Text>
<ColorSchemeSwitch/>
<Switch
checked={showHelp}
onChange={toggleShowHelp}
@ -156,31 +159,34 @@ export default function UserMenuModal() {
/>
<Group justify={"center"}>
<Tooltip label={"Email ändern"}>
<ActionIcon
variant={"transparent"}
aria-label={"change email"}
onClick={() => {
handler.close()
changeEmailHandler.open()
}}
>
<IconMailCog/>
</ActionIcon>
</Tooltip>
<Tooltip label={"Passwort zurücksetzen"}>
<ActionIcon
variant={"transparent"}
aria-label={"change password"}
onClick={() => {
handler.close()
passwordResetHandler.open()
}}
>
<IconPassword/>
</ActionIcon>
</Tooltip>
{user?.REALM === "GUEST" && <>
<Tooltip label={"Email ändern"}>
<ActionIcon
variant={"transparent"}
aria-label={"change email"}
onClick={() => {
handler.close()
changeEmailHandler.open()
}}
>
<IconMailCog/>
</ActionIcon>
</Tooltip>
<Tooltip label={"Passwort zurücksetzen"}>
<ActionIcon
variant={"transparent"}
aria-label={"change password"}
onClick={() => {
handler.close()
passwordResetHandler.open()
}}
>
<IconPassword/>
</ActionIcon>
</Tooltip>
</>}
<Tooltip label={"Ausloggen"}>
<ActionIcon

View File

@ -5,12 +5,12 @@ export const useSearchParamToggle = (key: string) => {
const value = searchParams.get(key) === "true"
const open = () => {
const open = () => {
setSearchParams(prev => {
const newParams = new URLSearchParams(prev);
newParams.set(key, "true")
return newParams
}, { replace: true })
}, {replace: true})
}
const close = () => {
@ -18,7 +18,7 @@ export const useSearchParamToggle = (key: string) => {
const newParams = new URLSearchParams(prev);
newParams.delete(key);
return newParams;
}, { replace: true })
}, {replace: true})
}
return {
@ -31,6 +31,36 @@ export const useSearchParamToggle = (key: string) => {
}
}
export const useSearchParamsValue = (key: string, initialValue: string | null) => {
const [searchParams, setSearchParams] = useSearchParams()
const value = searchParams.get(key) ?? initialValue
const setValue = (value: string) => {
setSearchParams(prev => {
const newParams = new URLSearchParams(prev)
newParams.set(key, value)
return newParams
}, {replace: true})
}
const deleteValue = () => {
setSearchParams(prev => {
const newParams = new URLSearchParams(prev)
newParams.delete(key);
return newParams;
}, {replace: true})
}
return {
value: value,
handler: {
setValue,
delete: deleteValue,
}
}
}
export const useLogin = () => useSearchParamToggle("login")
export const useRegister = () => useSearchParamToggle("register")
@ -40,3 +70,5 @@ export const useUserMenu = () => useSearchParamToggle("userMenu")
export const useForgotPassword = () => useSearchParamToggle("forgotPassword")
export const useChangeEmail = () => useSearchParamToggle("changeEmail")
export const useShowMessages = () => useSearchParamToggle("showMessages")

View File

@ -9,7 +9,6 @@ export type EventModel = {
startDate: string;
endDate: string;
eventAdmins: string[];
eventListAdmins: string[];
img: string | null; // png, jpg, gif
location: string | null;
isStuveEvent: boolean;
@ -18,9 +17,10 @@ export type EventModel = {
eventLinks: EventLink[];
defaultEntryQuestionSchema: FormSchema | null;
defaultEntryStatusSchema: FormSchema | null;
privilegedLists: string[];
expand?: {
eventAdmins: UserModal[] | null;
eventListAdmins: UserModal[] | null;
privilegedLists: EventListModel[] | null;
}
} & RecordModel

View File

@ -0,0 +1,33 @@
import {RecordModel} from "pocketbase";
import {UserModal} from "@/models/AuthTypes.ts";
export type MessagesModel = {
sender: string
recipients: string[]
thread: string | null
subject: string | null
content: string
repliedTo: string | null
isAnnouncement: boolean | null
expand: {
sender: UserModal
recipients: UserModal[]
thread: MessageThreadsModel | null
repliedTo: MessagesModel | null
}
} & RecordModel
export type MessageThreadsModel = {
name: string
participants: string[]
img: string | null
systemThread: boolean | null
expand: {
participants: UserModal[]
}
} & RecordModel

View File

@ -8,6 +8,7 @@ import {
EventListSlotsWithEntriesCountModel,
EventModel
} from "./EventTypes.ts";
import {MessagesModel, MessageThreadsModel} from "@/models/MessageTypes.ts";
export type SettingsModel = {
key: ['privacyPolicy', 'agb', 'stexGroup', 'stuveEventQuestions']
@ -26,6 +27,10 @@ export type LegalSettingsModal = {
export interface TypedPocketBase extends PocketBase {
collection(idOrName: string): RecordService<RecordModel>
collection(idOrName: 'messages'): RecordService<MessagesModel>
collection(idOrName: 'messageThreads'): RecordService<MessageThreadsModel>
collection(idOrName: 'settings'): RecordService<SettingsModel>
collection(idOrName: 'legalSettings'): RecordService<LegalSettingsModal>

View File

@ -0,0 +1,28 @@
.container {
max-height: 100%;
position: relative;
overflow: hidden;
margin: 0 auto;
max-width: var(--max-content-width);
}
.grid {
flex-grow: 1;
overflow-y: hidden;
display: flex;
flex-direction: row;
gap: var(--gap);
@media (min-width: 768px) {
& > :first-child {
max-width: 500px;
width: 30%;
min-width: 300px;
}
}
& > :last-child {
flex-grow: 1;
overflow: hidden;
}
}

View File

@ -0,0 +1,57 @@
import {Anchor, Breadcrumbs, Center} from "@mantine/core";
import {Link, Outlet, Route, Routes} from "react-router-dom";
import classes from "./ChatRouter.module.css";
import ConversationSvg from "@/illustrations/conversation.svg?react";
import {useMediaQuery} from "@mantine/hooks";
import MessageThreadsList from "@/pages/chat/MessageThreadsList.tsx";
import MessageThreadView from "@/pages/chat/MessageThreadView.tsx";
import {usePB} from "@/lib/pocketbase.tsx";
import {useLogin} from "@/components/users/modals/hooks.ts";
import Announcements from "@/pages/chat/components/Announcements.tsx";
const ChatIndex = () => {
return <Center>
<ConversationSvg width={"100%"} height={"100%"}/>
</Center>
}
export default function ChatRouter() {
const isMobile = useMediaQuery("(max-width: 768px)")
const {user} = usePB()
const {handler} = useLogin()
if (!user) {
handler.toggle()
return null
}
return <div className={`${classes.container} stack`}>
<div className={"section-transparent"}>
<Breadcrumbs>{[
{title: "Home", to: "/"},
{title: "Nachrichten", to: "/chat"},
].map(({title, to}) => (
<Anchor component={Link} to={to} key={title}>
{title}
</Anchor>
))}</Breadcrumbs>
</div>
<div className={classes.grid}>
<Outlet/>
<Routes>
<Route path={isMobile ? "/" : "*"} element={<MessageThreadsList/>}/>
</Routes>
<Routes>
{!isMobile && <Route index element={<ChatIndex/>}/>}
<Route path={":threadId"} element={<MessageThreadView/>}/>
<Route path={"announcements"} element={<Announcements/>}/>
</Routes>
</div>
</div>
}

View File

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

View File

@ -0,0 +1,134 @@
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>
}

View File

@ -0,0 +1,49 @@
.container {
width: 100%;
transition: width 0.3s ease
}
.announcementLink {
display: flex;
flex-direction: row;
align-items: center;
padding: calc(var(--padding) / 2);
gap: var(--gap);
border: var(--border);
border-radius: var(--border-radius);
background-color: var(--mantine-color-body);
}
.threadText {
color: var(--mantine-color-dimmed);
width: calc(100% - 40px);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&[data-active="true"] {
color: var(--mantine-primary-color-5);
font-weight: 600;
}
}
.threadLink {
display: flex;
flex-direction: row;
align-items: center;
padding: calc(var(--padding) / 2);
gap: var(--gap);
}
.threadsContainer {
//flex-grow: 1;
overflow: auto;
border: var(--border);
border-radius: var(--border-radius);
background-color: var(--mantine-color-body);
& > :not(:last-child) {
border-bottom: var(--border);
}
}

View File

@ -0,0 +1,140 @@
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>
}

View File

@ -0,0 +1,27 @@
.announcement {
display: flex;
position: relative;
flex-direction: column;
box-shadow: var(--shadow);
background-color: var(--mantine-color-body);
padding: var(--padding);
border: var(--border);
border-color: var(--mantine-primary-color-5);
border-radius: var(--mantine-radius-lg);
margin: var(--gap);
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);
}

View File

@ -0,0 +1,27 @@
import InnerHtml from "@/components/InnerHtml";
import classes from './Announcement.module.css'
import {Group, ThemeIcon} from "@mantine/core";
import {IconSpeakerphone} from "@tabler/icons-react";
export default function Announcement({subject, content}: {
subject: string | null,
content: string,
}) {
return <div className={classes.announcement}>
<Group justify={"space-between"} wrap={"nowrap"} align={"top"} mb={"md"}>
{subject && <div className={classes.subject}>
{subject}
</div>}
<ThemeIcon
className={classes.icon}
variant={"transparent"} size={"sm"}
>
<IconSpeakerphone/>
</ThemeIcon>
</Group>
<InnerHtml html={content}/>
</div>
}

View File

@ -0,0 +1,6 @@
.announcements {
flex-grow: 1;
overflow-y: auto;
display: flex;
flex-direction: column-reverse;
}

View File

@ -0,0 +1,71 @@
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 {IconMessageCircleUp} 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, 100, {
filter: `isAnnouncement=true`,
sort: "-created"
})
),
getNextPageParam: (lastPage) =>
lastPage.page >= lastPage.totalPages ? undefined : lastPage.page + 1,
initialPageParam: 1,
enabled: !!user,
})
const announcements = query.data?.pages.flatMap(page => page.items) || []
if (query.isError) return (
<PocketBaseErrorAlert error={query.error}/>
)
if (query.isLoading || !query.data) return (
<Center>
<Loader/>
</Center>
)
return <div className={`stack `}>
<div className={`scrollbar ${classes.announcements}`}>
{announcements.map((announcement) => (
<Announcement
key={announcement.id}
subject={announcement.subject}
content={announcement.content}
/>
))}
{query.hasNextPage ? (
<Center>
<Button
variant={"transparent"} color={"blue"}
radius={"xl"}
onClick={() => query.fetchNextPage()}
leftSection={<IconMessageCircleUp/>}
loading={query.isFetchingNextPage}
>
Mehr laden
</Button>
</Center>
) : <div className={classes.text}>
<Text ta={"center"} size={"xs"} c={"dimmed"}>
{
announcements.length > 0 ?
"Keine weiteren Ankündigungen"
: "Noch keine Ankündigungen"
}
</Text>
</div>}
</div>
</div>
}

View File

@ -0,0 +1,58 @@
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 {useState} from "react";
import {useTimeout} from "@mantine/hooks";
import {MessagesModel} from "@/models/MessageTypes.ts";
export default function ChatNavIcon() {
const [newMessage, setNewMessage] = useState<string | null>(null);
const {start} = useTimeout(() => setNewMessage(null), 5000);
const {user, pb, useSubscription} = usePB()
const match = useMatch("/chat/:threadId")
useSubscription<MessagesModel>({
idOrName: "messages",
topic: "*",
callback: (event) => {
if (event.action == "create" && event.record.thread) {
pb.collection("messageThreads").getOne(event.record.thread).then(thread => {
if (thread.systemThread) {
start()
setNewMessage("announcements")
} else if (
match?.params.threadId !== event.record.thread // check if thread is not already open
&&
event.record.sender !== user?.id // check if sender is not the user
) {
start()
setNewMessage(event.record.thread)
}
})
}
}
})
if (!user) {
return null
}
return <>
<Indicator inline processing disabled={newMessage === null} classNames={{
root: "stack"
}}>
<ActionIcon
component={Link}
to={`/chat${newMessage ? `/${newMessage}` : ""}`}
variant={"transparent"}
color={"gray"}
>
<IconMessageCircle/>
</ActionIcon>
</Indicator>
</>
}

View File

@ -0,0 +1,55 @@
.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;
box-shadow: var(--shadow);
background-color: var(--mantine-color-body);
padding: var(--padding);
border: var(--border);
border-radius: var(--mantine-radius-xl);
border-top-left-radius: var(--mantine-radius-sm);
margin: var(--gap);
max-width: fit-content;
word-break: break-all;
overflow-wrap: break-word;
&[data-sender="true"] {
align-self: flex-end;
border-radius: var(--mantine-radius-xl);
border-bottom-right-radius: var(--mantine-radius-sm);
& > .messageSender {
color: var(--mantine-primary-color-4);
}
}
}
.messageSender {
font-size: var(--mantine-font-size-xs);
color: var(--mantine-color-dimmed)
}
.text {
height: 100vh;
}

View File

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

View File

@ -0,0 +1,76 @@
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>
}

View File

@ -19,7 +19,7 @@ export default function EventNavigate() {
const eventQuery = useQuery({
queryKey: ["event", eventId],
queryFn: async () => (await pb.collection("events").getOne(eventId, {
expand: "eventAdmins, eventListAdmins"
expand: "eventAdmins, privilegedLists"
}))
})

View File

@ -6,7 +6,7 @@ import {useMutation} from "@tanstack/react-query";
import dayjs from "dayjs";
import {UserModal} from "@/models/AuthTypes.ts";
import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx";
import UserInput from "@/components/auth/UserInput.tsx";
import UserInput from "@/components/users/UserInput.tsx";
import {EventModel} from "@/models/EventTypes.ts";
import ShowHelp from "@/components/ShowHelp.tsx";
import {useSettings} from "@/lib/settings.ts";

View File

@ -50,8 +50,8 @@ const EventRow = ({event}: { event: EventModel }) => {
const delta = humanDeltaFromNow(event.startDate, event.endDate)
return <>
<div className={classes.eventRow}>
<div className={classes.row}>
<div className={classes.eventContainer}>
<div className={classes.eventInfo}>
<div>
<PBAvatar model={event} name={event.name} img={event.img}/>
</div>

View File

@ -8,7 +8,7 @@
}
}
.eventRow {
.eventContainer {
display: flex;
flex-direction: column;
gap: var(--gap);
@ -30,13 +30,17 @@
}
}
.row {
.eventInfo {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
gap: var(--gap);
align-items: center;
@media (max-width: 768px) {
flex-direction: column;
}
}
.descriptionContainer {

View File

@ -86,7 +86,7 @@ export default function EditEventRouter() {
const eventQuery = useQuery({
queryKey: ["event", eventId],
queryFn: async () => (await pb.collection("events").getOne(eventId, {
expand: "eventAdmins, eventListAdmins"
expand: "eventAdmins, privilegedLists"
}))
})

View File

@ -1,9 +1,9 @@
import {EventModel} from "@/models/EventTypes.ts";
import classes from "../EditEventRouter.module.css";
import {IconAdjustments, IconCalendar, IconHourglass, IconList, IconMap, IconSparkles} from "@tabler/icons-react";
import {IconCalendar, IconHourglass, IconList, IconMap, IconSparkles} from "@tabler/icons-react";
import {areDatesSame, humanDeltaFromNow, pprintDate} from "@/lib/datetime.ts";
import UsersDisplay from "@/components/auth/UsersDisplay.tsx";
import {Text, ThemeIcon, Title} from "@mantine/core";
import UsersDisplay from "@/components/users/UsersDisplay.tsx";
import {List, Text, ThemeIcon, Title, Tooltip} from "@mantine/core";
/**
* Displays the event data
@ -22,7 +22,6 @@ export default function EventData({event, hideHeader}: { event: EventModel, hide
return (
<div className={`section`}>
{!hideHeader && <Title order={2}>Event Übersicht</Title>}
<div className={classes.data}>
@ -81,24 +80,26 @@ export default function EventData({event, hideHeader}: { event: EventModel, hide
{
event.expand?.eventAdmins &&
<div className={classes.stack}>
<div className={"group"}>
<IconAdjustments size={16}/>
Event Admins
<Tooltip label={"Event Admins"} position={"top-start"}>
<div>
<UsersDisplay users={event.expand.eventAdmins}/>
</div>
<UsersDisplay users={event.expand.eventAdmins}/>
</div>
</Tooltip>
}
{
event.expand?.eventListAdmins &&
<div className={classes.stack}>
<div className={"group"}>
<IconList size={16}/>
Listen Admins
</div>
<UsersDisplay users={event.expand.eventListAdmins}/>
</div>
event.expand?.privilegedLists &&
<Tooltip label={"Privilegierte Listen"} position={"top-start"}>
<List size={"sm"} icon={<IconList size={10}/>}>
{
event.expand?.privilegedLists.map((l) => (
<List.Item key={l.id} fw={500}>
{l.name}
</List.Item>
))
}
</List>
</Tooltip>
}
</div>
</div>

View File

@ -1,13 +1,14 @@
import {EventModel} from "@/models/EventTypes.ts";
import {EventListModel, EventModel} from "@/models/EventTypes.ts";
import {useForm} from "@mantine/form";
import {UserModal} from "@/models/AuthTypes.ts";
import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx";
import {Button, Group, Title} from "@mantine/core";
import UserInput from "@/components/auth/UserInput.tsx";
import UserInput from "@/components/users/UserInput.tsx";
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,14 +21,10 @@ export default function EditEventMembers({event}: { event: EventModel }) {
const formValues = useForm({
initialValues: {
eventAdmins: event?.expand?.eventAdmins ?? [user] as UserModal[],
eventListAdmins: event?.expand?.eventListAdmins ?? [] as UserModal[],
privilegedLists: event?.expand?.privilegedLists ?? [] as EventListModel[]
},
validate: {
eventAdmins: (value) => value.length > 0 ? null : "Es muss mindestens ein Admin ausgewählt werden.",
eventListAdmins: (value, values) =>
value.filter((admin) => values.eventAdmins.map((admin) => admin.id).includes(admin.id)).length === 0
? null
: "Ein Event Admin kann nicht gleichzeitig Listen Admin sein."
}
})
@ -35,17 +32,16 @@ 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],
eventListAdmins: formValues.values.eventListAdmins.map((member) => member.id)
privilegedLists: formValues.values.privilegedLists.map((member) => member.id)
})
},
onSuccess: () => {
showSuccessNotification("Event und Listen Admins gespeichert")
showSuccessNotification("Event Teilnehmende gespeichert")
return queryClient.invalidateQueries({queryKey: ["event", event.id]})
}
})
return <form className="stack" onSubmit={formValues.onSubmit(() => editMutation.mutate())}>
<Title order={4} c={"blue"}>Event Admins</Title>
<ShowHelp>
@ -55,17 +51,18 @@ export default function EditEventMembers({event}: { event: EventModel }) {
<br/>
<br/>
<Title order={6}>Listen Admins</Title>
Listen Admin können Listen bearbeiten und verwalten.
Sie können <b>keine</b> Einstellungen des Events bearbeiten.
<Title order={6}>Privilegierte Listen</Title>
Alle Teilnehmenden in privilegierten Listen können alle Anmeldungen von allen Listen
dieses Events sehen und den Status von allen Teilnehmenden bearbeiten.
<br/>
Du kannst eine privilegierte Liste z.B. für die Event-Orgs erstellen, so dass diese
alle Anmeldungen sehen und bearbeiten können.
<br/>
<br/>
Eine Person kann nicht gleichzeitig Event Admin und Listen Admin sein.
</ShowHelp>
<PocketBaseErrorAlert error={editMutation.error}/>
<UserInput
required
label={"Event Admins"}
@ -75,12 +72,12 @@ export default function EditEventMembers({event}: { event: EventModel }) {
setSelectedRecords={(records) => formValues.setFieldValue("eventAdmins", records)}
/>
<UserInput
label={"Listen Admins"}
description={"Die Listen Admins können Listen bearbeiten und verwalten."}
error={formValues.errors.eventListAdmins}
selectedRecords={formValues.values.eventListAdmins}
setSelectedRecords={(records) => formValues.setFieldValue("eventListAdmins", records)}
<ListSelect
label={"Privilegierte Listen"}
description={"Teilnehmende in privilegierten Listen können alle Event-Anmeldungen sehen und deren Status bearbeiten."}
event={event}
selectedRecords={formValues.values.privilegedLists}
setSelectedRecords={(records) => formValues.setFieldValue("privilegedLists", records)}
/>
<Group>

View File

@ -11,7 +11,7 @@ import {IconCheckupList, IconForms, IconUserMinus, IconUserPlus} from "@tabler/i
import {renderEntries} from "@/components/formUtil/formTable";
import RenderCell from "@/components/formUtil/formTable/RenderCell.tsx";
import EditSlotEntryMenu from "@/pages/events/e/:eventId/EventLists/components/EditSlotEntryMenu.tsx";
import {RenderUserName} from "@/components/auth/modals/util.tsx";
import {RenderUserName} from "@/components/users/modals/util.tsx";
export const EventListSlotEntryDetails = ({entry}: {

View File

@ -10,7 +10,7 @@ import {
import {useDisclosure} from "@mantine/hooks";
import {RenderDateRange} from "@/pages/events/e/:eventId/EventLists/components/RenderDateRange.tsx";
import EditSlotEntryMenu from "@/pages/events/e/:eventId/EventLists/components/EditSlotEntryMenu.tsx";
import {RenderUserName} from "@/components/auth/modals/util.tsx";
import {RenderUserName} from "@/components/users/modals/util.tsx";
export default function EventListSearchResult({entry, refetch}: {
entry: EventListSlotEntriesWithUserModel,

View File

@ -25,7 +25,7 @@ export default function ListSearch({event}: { event: EventModel }) {
const {pb} = usePB()
const [showFilter, showFilterHandler] = useDisclosure(false)
const [showFilter, showFilterHandler] = useDisclosure(true)
const [searchQueryString, setSearchQueryString] = useState('')
const [selectedLists, setSelectedLists] = useState<EventListModel[]>([])
@ -73,11 +73,12 @@ export default function ListSearch({event}: { event: EventModel }) {
</Tooltip>
)}
data={
searchQuery
(searchQuery
.data
?.items
.map(e => e.id)
.filter(onlyUnique) ?? []
.map(e => e.expand?.user.username)
.filter(u => u !== undefined)
.filter(onlyUnique) ?? []) as string[]
}
value={searchQueryString} onChange={setSearchQueryString}
/>
@ -102,17 +103,18 @@ export default function ListSearch({event}: { event: EventModel }) {
</Group>
{/*
todo: more filter
<div className={"section stack"}>
<Title order={4} c={"blue"}>
Filter
</Title>
<Text> Listenauswahl </Text>
<Text> Formularfelder Auswahl </Text>
<Text> Statusfelder Auswahl </Text>
</div>
*/}
{
/*
todo: more filter
<div className={"section stack"}>
<Title order={4} c={"blue"}>
Filter
</Title>
<Text> Formularfelder Auswahl </Text>
<Text> Statusfelder Auswahl </Text>
</div>
*/
}
{
searchQuery.data?.items.map((entry, index) => (

View File

@ -57,6 +57,7 @@ const nav = [
* @param target - the trigger element for the dropdown
*/
export const EventListsMenu = ({event, target}: { event: EventModel, target: ReactNode }) => {
return <>
<Menu
withArrow shadow="md" width={200} trigger="click-hover" loop={false}

View File

@ -15,7 +15,7 @@ import {
} from "@/pages/events/e/:eventId/EventLists/components/UpdateEventListSlotEntryFormModal.tsx";
import {ActionIcon, Button, Menu} from "@mantine/core";
import {IconArrowsMove, IconCheckupList, IconForms, IconSettings, IconTrash} from "@tabler/icons-react";
import {getUserName} from "@/components/auth/modals/util.tsx";
import {getUserName} from "@/components/users/modals/util.tsx";
export default function EditSlotEntryMenu({entry, refetch}: {
refetch: () => void,

View File

@ -26,7 +26,7 @@ export default function EventListSlotProgress({slot}: { slot: EventListSlotsWith
}
// if the slot is full
if (slot.maxEntries < slot.entriesCount) {
if (slot.maxEntries <= slot.entriesCount) {
return (
<Group align={"center"} justify={"center"}>
<ThemeIcon size={"sm"} variant={"transparent"} color={"orange"}>

View File

@ -5,7 +5,7 @@ import {showSuccessNotification} from "@/components/util.tsx";
import {Alert, Button, Group, Modal, Select} from "@mantine/core";
import {useEffect, useState} from "react";
import {pprintDateTime} from "@/lib/datetime.ts";
import {getUserName, RenderUserName} from "@/components/auth/modals/util.tsx";
import {getUserName, RenderUserName} from "@/components/users/modals/util.tsx";
export const MoveEventListSlotEntryModal = ({opened, close, refetch, entry}: {
opened: boolean,

View File

@ -5,7 +5,7 @@ import {FieldEntries} from "@/components/formUtil/FromInput/types.ts";
import {showSuccessNotification} from "@/components/util.tsx";
import {Modal} from "@mantine/core";
import FormInput from "@/components/formUtil/FromInput";
import {getUserName, RenderUserName} from "@/components/auth/modals/util.tsx";
import {getUserName, RenderUserName} from "@/components/users/modals/util.tsx";
export const UpdateEventListSlotEntryFormModal = ({opened, close, refetch, entry}: {
opened: boolean,

View File

@ -7,7 +7,7 @@ import {Alert, Modal} from "@mantine/core";
import InnerHtml from "@/components/InnerHtml";
import {RenderDateRange} from "./RenderDateRange.tsx";
import FormInput from "@/components/formUtil/FromInput";
import {getUserName, RenderUserName} from "@/components/auth/modals/util.tsx";
import {getUserName, RenderUserName} from "@/components/users/modals/util.tsx";
export const UpdateEventListSlotEntryStatusModal = ({opened, close, refetch, entry}: {
opened: boolean,

View File

@ -1,4 +1,4 @@
import PromptLoginModal from "@/components/auth/modals/PromptLoginModal.tsx";
import PromptLoginModal from "@/components/users/modals/PromptLoginModal.tsx";
import {Link, useNavigate} from "react-router-dom";
import {useState} from "react";
import {useDebouncedValue, useToggle} from "@mantine/hooks";

View File

@ -5,6 +5,28 @@
padding: var(--mantine-spacing-xs);
}
.entryInfo {
display: flex;
justify-content: start;
align-items: center;
gap: var(--gap);
@media (min-width: 768px) {
& > :nth-child(1) {
width: 20%;
}
& > :nth-child(2) {
width: 20%;
}
}
@media (max-width: 768px) {
flex-direction: column;
}
}
.row {
display: flex;
justify-content: start;
@ -14,14 +36,7 @@
font-size: var(--mantine-font-size-sm);
& > :nth-child(2) {
width: 20%;
}
& > :nth-child(3) {
width: 20%;
}
& > :nth-child(4) {
flex: 1;
}
}

View File

@ -17,7 +17,7 @@ import {usePB} from "@/lib/pocketbase.tsx";
import {
UpdateEventListSlotEntryFormModal
} from "@/pages/events/e/:eventId/EventLists/components/UpdateEventListSlotEntryFormModal.tsx";
import {getUserName} from "@/components/auth/modals/util.tsx";
import {getUserName} from "@/components/users/modals/util.tsx";
export default function UserEntryRow({entry, refetch}: {
entry: EventListSlotEntriesWithUserModel,
@ -65,29 +65,30 @@ export default function UserEntryRow({entry, refetch}: {
</ActionIcon>
</Tooltip>
<div className={classes.entryInfo}>
<TextWithIcon icon={
<ThemeIcon variant={"transparent"} size={"xs"} color={"gray"}>
<IconConfetti/>
</ThemeIcon>
}>
{entry.eventName}
</TextWithIcon>
<TextWithIcon icon={
<ThemeIcon variant={"transparent"} size={"xs"} color={"gray"}>
<IconConfetti/>
</ThemeIcon>
}>
{entry.eventName}
</TextWithIcon>
<TextWithIcon icon={
<ThemeIcon variant={"transparent"} size={"xs"} color={"gray"}>
<IconList/>
</ThemeIcon>
}>
{entry.listName}
</TextWithIcon>
<TextWithIcon icon={
<ThemeIcon variant={"transparent"} size={"xs"} color={"gray"}>
<IconList/>
</ThemeIcon>
}>
{entry.listName}
</TextWithIcon>
<Group gap={"xs"}>
<RenderDateRange start={new Date(entry.slotStartDate)} end={new Date(entry.slotEndDate)}/>
<Text size={"sm"} c={"dimmed"}>
{delta.message}
</Text>
</Group>
<Group gap={"xs"} justify={"center"}>
<RenderDateRange start={new Date(entry.slotStartDate)} end={new Date(entry.slotEndDate)}/>
<Text size={"sm"} c={"dimmed"}>
{delta.message}
</Text>
</Group>
</div>
<Group gap={"xs"}>
<Tooltip

View File

@ -8,10 +8,33 @@
align-items: center;
& > :nth-child(2) {
width: 40%;
flex-grow: 1;
}
& > :nth-child(3) {
flex: 1;
}
.slotInfo {
display: flex;
flex-direction: row;
gap: var(--gap);
@media (min-width: 768px) {
& > :nth-child(1) {
width: 50%;
}
& > :nth-child(2) {
flex: 1;
}
}
@media (max-width: 768px) {
flex-direction: column;
align-items: center;
& > :nth-child(2) {
width: 100%;
}
}
}

View File

@ -60,9 +60,13 @@ export default function EventListSlotView({slot, list, refetch}: {
</ThemeIcon>
</Tooltip>
<div className={classes.slotInfo}>
<RenderDateRange start={new Date(slot.startDate)} end={new Date(slot.endDate)}/>
<EventListSlotProgress slot={slot}/>
</div>
</UnstyledButton>
<Collapse in={expanded} className={"stack"}>

View File

@ -5,7 +5,7 @@ import NotFound from "../../not-found/index.page.tsx";
import {Accordion, Alert, Anchor, Breadcrumbs, Button, Center, Group, Loader, Title} from "@mantine/core";
import PBAvatar from "@/components/PBAvatar.tsx";
import InnerHtml from "@/components/InnerHtml";
import {IconExternalLink, IconLogin, IconPencil, IconSectionSign} from "@tabler/icons-react";
import {IconArchive, IconExternalLink, IconEye, IconLogin, IconPencil, IconSectionSign} from "@tabler/icons-react";
import EventData from "@/pages/events/e/:eventId/EventComponents/EventData.tsx";
import EventListView from "@/pages/events/s/EventListView.tsx";
import {useEventRights} from "@/pages/events/util.ts";
@ -13,7 +13,7 @@ import {useEventRights} from "@/pages/events/util.ts";
export default function SharedEvent() {
const {pb,user} = usePB()
const {pb, user} = usePB()
const {eventId} = useParams() as { eventId: string }
@ -40,6 +40,8 @@ export default function SharedEvent() {
const event = eventQuery.data
const eventIsArchived = event.eventAdmins.length === 0
return <>
<div className={"section-transparent stack center"}>
<PBAvatar
@ -51,12 +53,6 @@ export default function SharedEvent() {
</Title>
</div>
{!user && <div className={"section-transparent"}>
<Alert icon={<IconLogin/>} color={"orange"}>
Um dich in eine Liste einzutragen, musst du dich anmelden
</Alert>
</div>}
<div className={"section-transparent"}>
<Breadcrumbs>{[
{title: "Home", to: "/"},
@ -69,7 +65,32 @@ export default function SharedEvent() {
))}</Breadcrumbs>
</div>
{(canEditEvent || canEditEventList) && <div className={"section-transparent"}>
{!user && <div className={"section-transparent"}>
<Alert icon={<IconLogin/>} color={"orange"}>
Um dich in eine Liste einzutragen, musst du dich anmelden
</Alert>
</div>}
{eventIsArchived && <div className={"section-transparent"}>
<Alert color={"orange"} icon={<IconArchive/>}>
Dieses Event ist archiviert und wird nicht mehr verwaltet
</Alert>
</div>}
{canEditEventList && <div className={"section-transparent"}>
<Alert color={"green"} title={"Du kannst dieses Event und alle Teilnehmenden ansehen"}>
<Button
component={"a"}
href={`/events/${eventId}`}
variant={"light"}
leftSection={<IconEye/>}
>
Event ansehen
</Button>
</Alert>
</div>}
{canEditEvent && <div className={"section-transparent"}>
<Alert color={"green"} title={"Du kannst dieses Event bearbeiten"}>
<Button
component={"a"}

View File

@ -15,10 +15,9 @@ export const useEventRights = (event?: EventModel) => {
user.expand?.memberOf?.map((g: LdapGroupModel) => g.cn).includes(settings.stexGroupId.value)
)
const isEventAdmin = !!(user && event && event.eventAdmins.includes(user.id))
const isEventListAdmin = !!(user && event && event.eventListAdmins.includes(user.id))
return {
canEditEvent: isEventAdmin || isStex,
canEditEventList: isEventListAdmin || isEventAdmin || isStex
canEditEventList: false
}
}

View File

@ -0,0 +1,8 @@
.announcementsContainer {
max-height: 60vh;
display: flex;
& > * {
max-height: 100%;
flex-grow: 1;
}
}

View File

@ -1,27 +1,40 @@
import {EventCalendar} from "@/pages/events/EventOverview/EventCalendar.tsx";
import {Alert, Button, Group, ThemeIcon, Title} from "@mantine/core";
import {IconConfetti, IconHandLoveYou, IconQrcode} from "@tabler/icons-react";
import {IconConfetti, IconHandLoveYou, IconMessageCircle, IconSpeakerphone, IconUser} from "@tabler/icons-react";
import {usePB} from "@/lib/pocketbase.tsx";
import {NavLink} from "react-router-dom";
import Announcements from "@/pages/chat/components/Announcements.tsx";
import classes from './index.module.css'
import {ReactNode} from "react";
const NavButtons = ({buttons}: {
buttons: { title: string, icon: ReactNode, to: string }[]
}) => {
return <Group className={"section-transparent"} gap={"xs"}>
{
buttons.map(({title, icon, to}) => (
<NavLink to={to} replace key={to}>
{({isActive}) => (
<Button
component={"span"}
color={isActive ? "green" : "blue"}
variant={"subtle"}
size={"sm"}
leftSection={icon}
>
{title}
</Button>
)}
</NavLink>
))
}
</Group>
}
export default function HomePage() {
const {user} = usePB()
const nav = [
{
title: "Events",
icon: <IconConfetti/>,
to: `/events`
},
{
title: "QR Generator",
icon: <IconQrcode/>,
to: `/util/qr`
}
]
return <>
<div className={"section"}>
@ -41,30 +54,29 @@ export default function HomePage() {
</>}
</div>
<Group className={"section-transparent"}>
{
nav.map(({title, icon, to}) => (
<NavLink to={to} replace key={to}>
{({isActive}) => (
<Button
radius={"xl"}
component={"span"}
color={isActive ? "green" : "blue"}
variant={"outline"}
size={"md"}
leftSection={icon}
>
{title}
</Button>
)}
</NavLink>
))
}
</Group>
{user && <>
<div className={"section stack"}>
<Title order={2}>Ankündigungen</Title>
<div className={"section"}>
<NavButtons buttons={[
{title: "Ankündigungen", icon: <IconSpeakerphone/>, to: "/chat/announcements"},
{title: "Chat", icon: <IconMessageCircle/>, to: "/chat"},
]}/>
<div className={classes.announcementsContainer}>
<Announcements/>
</div>
</div>
</>}
<div className={"section stack"}>
<Title order={2}>StuVe Events</Title>
<NavButtons buttons={[
{title: "Deine Anmeldungen", icon: <IconUser/>, to: "/events/entries"},
{title: "Events", icon: <IconConfetti/>, to: "/events"},
]}/>
<EventCalendar/>
</div>
</>

View File

@ -0,0 +1,102 @@
import {useShowDebug} from "@/components/ShowDebug.tsx";
import {ActionIcon, Alert, Group, LoadingOverlay, Text, TextInput, Title} from "@mantine/core";
import {useForm} from "@mantine/form";
import {useQuery} from "@tanstack/react-query";
import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx";
import {CodeHighlight} from "@mantine/code-highlight";
import {IconRefresh} from "@tabler/icons-react";
export default function DebugPage() {
const {showDebug} = useShowDebug()
const {pb} = usePB()
const formValues = useForm({
initialValues: {
collectionName: "",
filter: "",
sort: "",
expand: "",
}
})
const debugQuery = useQuery({
queryKey: ["debug", formValues.values.collectionName, formValues.values.filter, formValues.values.sort, formValues.values.expand],
queryFn: async () => {
return await pb.collection(formValues.values.collectionName).getList(1, 10, {
filter: formValues.values.filter,
sort: formValues.values.sort,
expand: formValues.values.expand
})
},
enabled: formValues.values.collectionName !== ""
})
if (!showDebug) return (
<div className={"section-transparent"}>
<Alert color={"orange"}>
Der Debug Modus ist deaktiviert. Bitte aktiviere ihn in den Einstellungen um diese Seite zu sehen.
</Alert>
</div>)
return <>
<div className={"section"}>
<Title c={"orange"} order={1}>Debug</Title>
</div>
<div className={"section stack"}>
<TextInput
label={"Collection Name"}
placeholder={"collectionName"}
{...formValues.getInputProps("collectionName")}
/>
<TextInput
label={"Filter"}
placeholder={"filter"}
{...formValues.getInputProps("filter")}
/>
<TextInput
label={"Sort"}
placeholder={"sort"}
{...formValues.getInputProps("sort")}
/>
<TextInput
label={"Expand"}
placeholder={"expand"}
{...formValues.getInputProps("expand")}
/>
</div>
<div className={"section stack"} style={{position: "relative"}}>
<LoadingOverlay visible={debugQuery.isLoading}/>
<PocketBaseErrorAlert error={debugQuery.error}/>
{debugQuery.data && <div className={"stack"}>
<Group justify={"space-between"}>
<Text c={"dimmed"}>
{debugQuery.data.totalItems} Ergebniss(e)
</Text>
<Text c={"dimmed"}>
{debugQuery.data.page}/{debugQuery.data.totalPages} Seiten
</Text>
<ActionIcon
onClick={() => debugQuery.refetch()}
color={"orange"}
variant={"transparent"}
size={"sm"}
aria-label={"Refetch"}
disabled={debugQuery.isLoading}
>
<IconRefresh/>
</ActionIcon>
</Group>
<CodeHighlight code={JSON.stringify(debugQuery.data.items, null, 2)} language="json"/>
</div>}
</div>
</>
}

View File

@ -17,6 +17,10 @@
stroke-width: 1.5;
}
.scrollable {
overflow-y: auto;
}
.scrollbar {
scrollbar-color: var(--mantine-color-blue-5) var(--mantine-color-body);
scrollbar-width: thin;
@ -46,6 +50,10 @@
}
}
.overflow-hidden {
overflow: hidden;
}
.no-scrollbar {
/* Hide scrollbar for WebKit (Safari, Chrome) */

View File

@ -26,6 +26,9 @@
"paths": {
"@/*": [
"src/*"
],
"~/*": [
"public/*"
]
},
"types": [

View File

@ -8,8 +8,8 @@ export default defineConfig({
plugins: [react(), svgr()],
resolve: {
alias: {
// Add aliases @ for src directory
"@": path.resolve(__dirname, "src"),
}
"~": path.resolve(__dirname, "public"),
},
}
})

View File

@ -880,6 +880,11 @@
resolved "https://registry.yarnpkg.com/@tiptap/extension-list-item/-/extension-list-item-2.3.0.tgz#7f0af465c1af160648ace586100ff3f8e612e9bf"
integrity sha512-mHU+IuRa56OT6YCtxf5Z7OSUrbWdKhGCEX7RTrteDVs5oMB6W3oF9j88M5qQmZ1WDcxvQhAOoXctnMt6eX9zcA==
"@tiptap/extension-mention@^2.4.0":
version "2.4.0"
resolved "https://registry.yarnpkg.com/@tiptap/extension-mention/-/extension-mention-2.4.0.tgz#35f13d71e207280cafe5b00e76f17b4c372fbe8b"
integrity sha512-7BqCNfqF1Mv9IrtdlHADwXMFo968UNmthf/TepVXC7EX2Ke6/Y4vvxmpYVNZc55FdswFwpVyZ2VeXBj3AC2JcA==
"@tiptap/extension-ordered-list@^2.3.0":
version "2.3.0"
resolved "https://registry.yarnpkg.com/@tiptap/extension-ordered-list/-/extension-ordered-list-2.3.0.tgz#75f7f668201a4cd3ec507c78d2229ec670e3e707"
@ -967,6 +972,11 @@
"@tiptap/extension-strike" "^2.3.0"
"@tiptap/extension-text" "^2.3.0"
"@tiptap/suggestion@^2.4.0":
version "2.4.0"
resolved "https://registry.yarnpkg.com/@tiptap/suggestion/-/suggestion-2.4.0.tgz#1926cde5f197d116baf7794f55bd971245540e5c"
integrity sha512-6dCkjbL8vIzcLWtS6RCBx0jlYPKf2Beuyq5nNLrDDZZuyJow5qJAY0eGu6Xomp9z0WDK/BYOxT4hHNoGMDkoAg==
"@types/date-arithmetic@*":
version "4.1.4"
resolved "https://registry.yarnpkg.com/@types/date-arithmetic/-/date-arithmetic-4.1.4.tgz#bdb441f61a916f11af1874a8c2cf787f77ffcb94"
@ -3230,6 +3240,18 @@ react-dropzone@^14.2.3:
file-selector "^0.6.0"
prop-types "^15.8.1"
react-infinite-scroll-component@^6.1.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/react-infinite-scroll-component/-/react-infinite-scroll-component-6.1.0.tgz#7e511e7aa0f728ac3e51f64a38a6079ac522407f"
integrity sha512-SQu5nCqy8DxQWpnUVLx7V7b7LcA37aM7tvoWjTLZp1dk6EJibM5/4EJKzOnl07/BsM1Y40sKLuqjCwwH/xV0TQ==
dependencies:
throttle-debounce "^2.1.0"
react-intersection-observer@^9.10.2:
version "9.10.2"
resolved "https://registry.yarnpkg.com/react-intersection-observer/-/react-intersection-observer-9.10.2.tgz#d5b14f80c9a6bed525becc228db7dccac5d0ec1c"
integrity sha512-j2hGADK2hCbAlfaq6L3tVLb4iqngoN7B1fT16MwJ4J16YW/vWLcmAIinLsw0lgpZeMi4UDUWtHC9QDde0/P1yQ==
react-is@^16.13.1, react-is@^16.7.0:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
@ -3608,6 +3630,11 @@ text-table@^0.2.0:
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==
throttle-debounce@^2.1.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-2.3.0.tgz#fd31865e66502071e411817e241465b3e9c372e2"
integrity sha512-H7oLPV0P7+jgvrk+6mwwwBDmxTaxnu9HMXmloNLXwnNO0ZxZ31Orah2n8lU1eMPvsaowP2CX+USCgyovXfdOFQ==
tiny-invariant@^1.0.6:
version "1.3.3"
resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127"