feat(chat): fixed issue for mobile and added chat
Build and Push Docker image / build-and-push (push) Successful in 4m30s
Details
Build and Push Docker image / build-and-push (push) Successful in 4m30s
Details
This commit is contained in:
parent
03bd913d45
commit
4f53566b61
|
@ -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"
|
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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"}
|
||||
/>
|
||||
}
|
|
@ -21,7 +21,6 @@
|
|||
overflow: auto;
|
||||
}
|
||||
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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}>
|
||||
<footer className={`${classes.footer}`}>
|
||||
<div className={classes.logo}>
|
||||
<Image
|
||||
h={15}
|
||||
w={15}
|
||||
src={"/stuve-logo.svg"}
|
||||
alt={"StuVe IT Logo"}
|
||||
<StuVeLogo
|
||||
height={15}
|
||||
width={15}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<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 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>
|
||||
)
|
||||
}
|
|
@ -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);
|
||||
|
||||
& > * {
|
||||
|
|
|
@ -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/>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,19 +45,9 @@ export default function NavBar() {
|
|||
</Menu>
|
||||
|
||||
<div className={classes.actionIcons}>
|
||||
<ActionIcon
|
||||
variant={"transparent"}
|
||||
color={"gray"}
|
||||
onClick={toggleColorScheme}
|
||||
>
|
||||
{colorScheme === "dark" ?
|
||||
<IconSun/>
|
||||
:
|
||||
<IconMoon/>
|
||||
}
|
||||
</ActionIcon>
|
||||
|
||||
{user ?
|
||||
<>
|
||||
<ChatNavIcon/>
|
||||
<ActionIcon
|
||||
variant={"transparent"}
|
||||
color={"gray"}
|
||||
|
@ -67,6 +56,7 @@ export default function NavBar() {
|
|||
>
|
||||
<IconUserStar/>
|
||||
</ActionIcon>
|
||||
</>
|
||||
: (
|
||||
<ActionIcon
|
||||
variant={"transparent"}
|
||||
|
|
|
@ -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" : ""}>
|
|
@ -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
|
|
@ -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"
|
||||
|
|
@ -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
|
|
@ -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,6 +119,8 @@ export default function LoginModal() {
|
|||
<Divider label={"oder"}/>
|
||||
|
||||
<Group justify={"space-evenly"}>
|
||||
{
|
||||
formValues.values.authMethod === "guest" &&
|
||||
<Button
|
||||
size={"compact-xs"}
|
||||
variant={"transparent"}
|
||||
|
@ -129,6 +131,7 @@ export default function LoginModal() {
|
|||
>
|
||||
Passwort vergessen?
|
||||
</Button>
|
||||
}
|
||||
|
||||
<Button
|
||||
size={"compact-xs"}
|
|
@ -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"}
|
|
@ -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";
|
|
@ -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>
|
||||
</>
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
.messagesContainer {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: var(--gap);
|
||||
}
|
|
@ -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'}}>↓ 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>
|
||||
</>
|
||||
}
|
|
@ -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()
|
||||
|
@ -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,6 +159,8 @@ export default function UserMenuModal() {
|
|||
/>
|
||||
|
||||
<Group justify={"center"}>
|
||||
|
||||
{user?.REALM === "GUEST" && <>
|
||||
<Tooltip label={"Email ändern"}>
|
||||
<ActionIcon
|
||||
variant={"transparent"}
|
||||
|
@ -181,6 +186,7 @@ export default function UserMenuModal() {
|
|||
<IconPassword/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</>}
|
||||
|
||||
<Tooltip label={"Ausloggen"}>
|
||||
<ActionIcon
|
|
@ -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")
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
.backIcon {
|
||||
@media (min-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
.announcements {
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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"
|
||||
}))
|
||||
})
|
||||
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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"
|
||||
}))
|
||||
})
|
||||
|
||||
|
|
|
@ -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
|
||||
</div>
|
||||
<Tooltip label={"Event Admins"} position={"top-start"}>
|
||||
<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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}: {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
*/}
|
||||
*/
|
||||
}
|
||||
|
||||
{
|
||||
searchQuery.data?.items.map((entry, index) => (
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"}>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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,7 +65,7 @@ export default function UserEntryRow({entry, refetch}: {
|
|||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
<div className={classes.entryInfo}>
|
||||
<TextWithIcon icon={
|
||||
<ThemeIcon variant={"transparent"} size={"xs"} color={"gray"}>
|
||||
<IconConfetti/>
|
||||
|
@ -82,12 +82,13 @@ export default function UserEntryRow({entry, refetch}: {
|
|||
{entry.listName}
|
||||
</TextWithIcon>
|
||||
|
||||
<Group gap={"xs"}>
|
||||
<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
|
||||
|
|
|
@ -8,10 +8,33 @@
|
|||
align-items: center;
|
||||
|
||||
& > :nth-child(2) {
|
||||
width: 40%;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
& > :nth-child(3) {
|
||||
}
|
||||
|
||||
.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%;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"}>
|
||||
|
|
|
@ -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";
|
||||
|
@ -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"}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
.announcementsContainer {
|
||||
max-height: 60vh;
|
||||
display: flex;
|
||||
& > * {
|
||||
max-height: 100%;
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
}
|
|
@ -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) */
|
||||
|
||||
|
|
|
@ -26,6 +26,9 @@
|
|||
"paths": {
|
||||
"@/*": [
|
||||
"src/*"
|
||||
],
|
||||
"~/*": [
|
||||
"public/*"
|
||||
]
|
||||
},
|
||||
"types": [
|
||||
|
|
|
@ -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"),
|
||||
},
|
||||
}
|
||||
})
|
27
yarn.lock
27
yarn.lock
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue