feat: FormBuilder
This commit is contained in:
parent
253bb24224
commit
f0318195c1
32
config.ts
32
config.ts
|
@ -1,45 +1,45 @@
|
||||||
/**
|
/**
|
||||||
* @description Global configuration file for the application
|
* @description Global configuration file for the application
|
||||||
*/
|
*/
|
||||||
import {IconHome, IconInfoCircle, IconShovel, TablerIconsProps} from "@tabler/icons-react";
|
import {IconHome, IconInfoCircle, IconQrcode, TablerIconsProps} from "@tabler/icons-react";
|
||||||
import {ReactNode} from "react";
|
import {ReactNode} from "react";
|
||||||
|
|
||||||
// POCKETBASE
|
// POCKETBASE
|
||||||
export const PB_USER_COLLECTION = "ldap_users"
|
export const PB_USER_COLLECTION = "ldap_users"
|
||||||
export const PB_BASE_URL = "https://stuve.uni-ulm.de"
|
export const PB_BASE_URL = "https://it.stuve.uni-ulm.de"
|
||||||
export const PB_STORAGE_KEY = "stuve-it-ldap-login"
|
export const PB_STORAGE_KEY = "stuve-it-ldap-login"
|
||||||
|
|
||||||
|
// general
|
||||||
|
export const APP_NAME = "StuVe IT"
|
||||||
|
export const APP_DESCRIPTION = "StuVe IT - Die IT-Abteilung der Studierendenvertretung der Universität Ulm"
|
||||||
|
export const APP_VERSION = "0.1.0"
|
||||||
|
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
export const NAV_ITEMS = [
|
export const NAV_ITEMS = [
|
||||||
{
|
{
|
||||||
section: "General",
|
section: "Seiten",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "Home",
|
title: "Home",
|
||||||
icon: IconHome,
|
icon: IconHome,
|
||||||
description: "Home",
|
description: "Home",
|
||||||
link: "/"
|
link: "/"
|
||||||
}
|
},
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
section: "Events",
|
|
||||||
items: [
|
|
||||||
{
|
{
|
||||||
title: "Übersicht",
|
title: "Events",
|
||||||
icon: IconInfoCircle,
|
icon: IconInfoCircle,
|
||||||
description: "Administration für StuVe Events. Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum. Quisquam, voluptatum. Quisquam,",
|
description: "Administration für StuVe Events.",
|
||||||
link: "/events"
|
link: "/events"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Listen",
|
title: "QR Code Generator",
|
||||||
icon: IconShovel,
|
icon: IconQrcode,
|
||||||
description: "Administration für StuVe Events. Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum. Quisquam, voluptatum. Quisquam,",
|
description: "Generiere einen QR Code",
|
||||||
link: "/events/lists"
|
link: "/util/qr"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
] as {
|
] as {
|
||||||
section: string,
|
section: string,
|
||||||
items: {
|
items: {
|
||||||
|
|
36
package.json
36
package.json
|
@ -12,23 +12,46 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/fira-code": "^5.0.15",
|
"@fontsource/fira-code": "^5.0.15",
|
||||||
"@fontsource/overpass": "^5.0.15",
|
"@fontsource/overpass": "^5.0.15",
|
||||||
"@mantine/core": "^7.1.5",
|
"@hello-pangea/dnd": "^16.6.0",
|
||||||
"@mantine/form": "^7.1.5",
|
"@mantine/code-highlight": "^7.8.0",
|
||||||
"@mantine/hooks": "^7.1.5",
|
"@mantine/core": "^7.8.0",
|
||||||
"@mantine/notifications": "^7.1.5",
|
"@mantine/dates": "^7.8.0",
|
||||||
|
"@mantine/form": "^7.8.0",
|
||||||
|
"@mantine/hooks": "^7.8.0",
|
||||||
|
"@mantine/notifications": "^7.8.0",
|
||||||
|
"@mantine/tiptap": "^7.8.0",
|
||||||
"@tabler/icons-react": "^2.39.0",
|
"@tabler/icons-react": "^2.39.0",
|
||||||
"@tanstack/react-query": "^5.0.5",
|
"@tanstack/react-query": "^5.0.5",
|
||||||
|
"@tiptap/extension-collaboration": "^2.3.0",
|
||||||
|
"@tiptap/extension-collaboration-cursor": "^2.3.0",
|
||||||
|
"@tiptap/extension-link": "^2.3.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",
|
||||||
|
"@types/react-big-calendar": "^1.8.9",
|
||||||
|
"dayjs": "^1.11.10",
|
||||||
"jwt-decode": "^3.1.2",
|
"jwt-decode": "^3.1.2",
|
||||||
"ms": "^2.1.3",
|
"ms": "^2.1.3",
|
||||||
"pocketbase": "^0.19.0",
|
"pocketbase": "^0.19.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
"react-big-calendar": "^1.11.3",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.20.0"
|
"react-dropzone": "^14.2.3",
|
||||||
|
"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",
|
||||||
|
"tiptap": "^1.32.2",
|
||||||
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/ms": "^0.7.33",
|
"@types/ms": "^0.7.33",
|
||||||
"@types/react": "^18.2.15",
|
"@types/react": "^18.2.15",
|
||||||
"@types/react-dom": "^18.2.7",
|
"@types/react-dom": "^18.2.7",
|
||||||
|
"@types/sanitize-html": "^2.11.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||||
"@typescript-eslint/parser": "^6.0.0",
|
"@typescript-eslint/parser": "^6.0.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.3.2",
|
"@vitejs/plugin-react-swc": "^3.3.2",
|
||||||
|
@ -36,8 +59,9 @@
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.3",
|
"eslint-plugin-react-refresh": "^0.4.3",
|
||||||
"postcss": "^8.4.31",
|
"postcss": "^8.4.31",
|
||||||
"postcss-preset-mantine": "^1.9.0",
|
"postcss-preset-mantine": "^1.13.0",
|
||||||
"postcss-simple-vars": "^7.0.1",
|
"postcss-simple-vars": "^7.0.1",
|
||||||
|
"sass": "^1.75.0",
|
||||||
"typescript": "^5.0.2",
|
"typescript": "^5.0.2",
|
||||||
"vite": "^4.4.5"
|
"vite": "^4.4.5"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,11 @@
|
||||||
import {createBrowserRouter, RouterProvider} from "react-router-dom";
|
import {createBrowserRouter, RouterProvider} from "react-router-dom";
|
||||||
import HomePage from "./pages/home";
|
import HomePage from "./pages/home/index.page.tsx";
|
||||||
import NotFound from "./pages/not-found";
|
import NotFound from "./pages/not-found/index.page.tsx";
|
||||||
import Layout from "./components/layout";
|
import Layout from "./components/layout";
|
||||||
|
import QRCodeGenerator from "./pages/util/qr/index.page.tsx";
|
||||||
|
import EventRouter from "./pages/events/router.tsx";
|
||||||
|
import PrivacyPolicy from "./pages/privacy-policy.page.tsx";
|
||||||
|
import TermsAndConditions from "./pages/terms-and-conditions.page.tsx";
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
|
@ -12,6 +16,31 @@ const router = createBrowserRouter([
|
||||||
index: true,
|
index: true,
|
||||||
element: <HomePage/>
|
element: <HomePage/>
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "privacy-policy",
|
||||||
|
element: <PrivacyPolicy/>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "imprint",
|
||||||
|
element: <PrivacyPolicy/>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "terms-and-conditions",
|
||||||
|
element: <TermsAndConditions/>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "events/*",
|
||||||
|
element: <EventRouter/>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "util",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "qr",
|
||||||
|
element: <QRCodeGenerator/>
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "*",
|
path: "*",
|
||||||
element: <NotFound/>
|
element: <NotFound/>
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
.customHtml {
|
||||||
|
& > h1 {
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > h2 {
|
||||||
|
font-size: 1.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > h3 {
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > h4 {
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
& > h5 {
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
import {TypographyStylesProvider, TypographyStylesProviderProps} from "@mantine/core";
|
||||||
|
import classes from "./index.module.css";
|
||||||
|
import sanitizeHtml from 'sanitize-html';
|
||||||
|
|
||||||
|
export default function InnerHtml({html, ...props}: { html: string } & TypographyStylesProviderProps) {
|
||||||
|
return <>
|
||||||
|
<TypographyStylesProvider {...props}>
|
||||||
|
<div
|
||||||
|
className={classes.customHtml}
|
||||||
|
dangerouslySetInnerHTML={{__html: sanitizeHtml(html)}}
|
||||||
|
/>
|
||||||
|
</TypographyStylesProvider>
|
||||||
|
</>
|
||||||
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
import {Avatar, AvatarProps} from "@mantine/core";
|
||||||
|
import {usePB} from "../lib/pocketbase.tsx";
|
||||||
|
import React from "react";
|
||||||
|
import {RecordModel} from "pocketbase";
|
||||||
|
|
||||||
|
|
||||||
|
const PBAvatar = React.forwardRef<
|
||||||
|
HTMLDivElement, { name: string, img?: string, model: RecordModel } & AvatarProps
|
||||||
|
>(({model, img, name, ...props}, ref) => {
|
||||||
|
const {pb} = usePB()
|
||||||
|
const avatarSrc = img ? pb.files.getUrl(model, img) : null
|
||||||
|
return <Avatar
|
||||||
|
ref={ref}
|
||||||
|
src={avatarSrc}
|
||||||
|
alt={name}
|
||||||
|
color={"blue"}
|
||||||
|
radius="xl"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{name.slice(0, 2).toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
})
|
||||||
|
|
||||||
|
PBAvatar.displayName = 'PBAvatar'
|
||||||
|
|
||||||
|
export default PBAvatar
|
|
@ -0,0 +1,41 @@
|
||||||
|
import RecordSearchInput, {GenericRecordSearchInputProps} from "../input/RecordSearchInput.tsx";
|
||||||
|
import {LdapGroupModel} from "../../models/AuthTypes.ts";
|
||||||
|
import {usePB} from "../../lib/pocketbase.tsx";
|
||||||
|
import {IconUsersGroup} from "@tabler/icons-react";
|
||||||
|
import {useMutation} from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export default function LdapGroupInput({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
selectedRecords,
|
||||||
|
setSelectedRecords,
|
||||||
|
placeholder
|
||||||
|
}: GenericRecordSearchInputProps<LdapGroupModel>) {
|
||||||
|
|
||||||
|
const {pb} = usePB()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RecordSearchInput
|
||||||
|
<LdapGroupModel>
|
||||||
|
label={label}
|
||||||
|
description={description}
|
||||||
|
selectedRecords={selectedRecords}
|
||||||
|
setSelectedRecords={setSelectedRecords}
|
||||||
|
recordToString={(group) => ({displayName: `${group.cn}`, description: group.description})}
|
||||||
|
placeholder={placeholder || "Suche nach Gruppen ..."}
|
||||||
|
leftSection={<IconUsersGroup size={18}/>}
|
||||||
|
recordSearchMutation={
|
||||||
|
useMutation({
|
||||||
|
mutationFn: async (search: string) => {
|
||||||
|
if (!search) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return (await pb.collection('ldap_groups').getList(1, 5, {
|
||||||
|
filter: `cn ~ "%${search}%"`,
|
||||||
|
})).items
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
import {List, Text, Tooltip} from "@mantine/core";
|
||||||
|
import {IconUsersGroup} from "@tabler/icons-react";
|
||||||
|
import {LdapGroupModel} from "../../models/AuthTypes.ts";
|
||||||
|
|
||||||
|
export default function LdapGroupsDisplay({groups}: { groups: LdapGroupModel[] }) {
|
||||||
|
return < >
|
||||||
|
|
||||||
|
<List size={"sm"}
|
||||||
|
icon={<IconUsersGroup size={"1rem"}/>}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
groups.map((group) => (
|
||||||
|
<List.Item key={group.id}>
|
||||||
|
<Tooltip label={group.description}>
|
||||||
|
<Text fw={500}>{group.cn}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
</List.Item>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</List>
|
||||||
|
</>
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
import {useMutation} from "@tanstack/react-query";
|
||||||
|
import {IconUsers} from "@tabler/icons-react";
|
||||||
|
import {usePB} from "../../lib/pocketbase.tsx";
|
||||||
|
import {LdapUserModel} from "../../models/AuthTypes.ts";
|
||||||
|
import RecordSearchInput, {GenericRecordSearchInputProps} from "../input/RecordSearchInput.tsx";
|
||||||
|
|
||||||
|
|
||||||
|
export default function UserInput(props: GenericRecordSearchInputProps<LdapUserModel>) {
|
||||||
|
|
||||||
|
const {pb} = usePB()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RecordSearchInput
|
||||||
|
<LdapUserModel>
|
||||||
|
recordToString={(user) => ({displayName: `${user.givenName} ${user.sn}`})}
|
||||||
|
placeholder={props.placeholder || "Suche nach Personen..."}
|
||||||
|
{...props}
|
||||||
|
leftSection={<IconUsers size={18}/>}
|
||||||
|
recordSearchMutation={
|
||||||
|
useMutation({
|
||||||
|
mutationFn: async (search: string) => {
|
||||||
|
if (!search) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await pb.collection('ldap_users').getList(1, 5, {
|
||||||
|
filter: `(username ~ "${
|
||||||
|
search.trim().toLowerCase().split(" ").map(s => s.trim()).join(".")
|
||||||
|
}%")`,
|
||||||
|
})).items
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
import {LdapUserModel} from "../../models/AuthTypes.ts";
|
||||||
|
import {List} from "@mantine/core";
|
||||||
|
import {IconUser} from "@tabler/icons-react";
|
||||||
|
import {usePB} from "../../lib/pocketbase.tsx";
|
||||||
|
|
||||||
|
export default function UsersDisplay({users}: { users: LdapUserModel[] }) {
|
||||||
|
|
||||||
|
const {user} = usePB()
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<List size={"sm"} icon={<IconUser size={"1rem"}/>}>
|
||||||
|
{
|
||||||
|
users.map((u) => (
|
||||||
|
<List.Item key={u.id} fw={500} c={user && user.id == u.id ? "green" : ""}>
|
||||||
|
{u.givenName} {u.sn}
|
||||||
|
</List.Item>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</List>
|
||||||
|
</>
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--gap);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
border: var(--border);
|
||||||
|
padding: var(--padding);
|
||||||
|
background-color: var(--mantine-color-body);
|
||||||
|
|
||||||
|
margin-bottom: var(--gap);
|
||||||
|
|
||||||
|
&[data-has-error=true]{
|
||||||
|
border-color: var(--mantine-color-error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--gap);
|
||||||
|
& > :nth-child(2) {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,364 @@
|
||||||
|
import {EmailField, FormSchema, FormSchemaField, SelectField} from "./types.ts";
|
||||||
|
import {UseFormReturnType} from "@mantine/form";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Fieldset,
|
||||||
|
Group,
|
||||||
|
NumberInput,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
ThemeIcon
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
IconDragDrop,
|
||||||
|
IconPlus,
|
||||||
|
IconSettings,
|
||||||
|
IconSettingsOff,
|
||||||
|
IconTrash,
|
||||||
|
IconTrashOff,
|
||||||
|
IconX
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import FormTypeIcon from "../formTypeIcon.tsx";
|
||||||
|
import {DateTimePicker} from "@mantine/dates";
|
||||||
|
import {useDisclosure} from "@mantine/hooks";
|
||||||
|
|
||||||
|
import classes from "./editField.module.css"
|
||||||
|
import {Draggable} from "@hello-pangea/dnd";
|
||||||
|
import {humanReadableField} from "../util.ts";
|
||||||
|
|
||||||
|
type EditFieldProps = {
|
||||||
|
index: number;
|
||||||
|
field: FormSchemaField;
|
||||||
|
formValues: UseFormReturnType<FormSchema>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation settings for text fields
|
||||||
|
* Min length, max length and regex
|
||||||
|
*/
|
||||||
|
const TextFieldSettings = ({formValues, index}: Omit<EditFieldProps, "field">) => {
|
||||||
|
return <>
|
||||||
|
<NumberInput
|
||||||
|
label={"Minimale Zeichenanzahl"}
|
||||||
|
placeholder={"unbegrenzt"}
|
||||||
|
{...formValues.getInputProps(`fields.${index}.validator.minLength`)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label={"Maximale Zeichenanzahl"}
|
||||||
|
placeholder={"unbegrenzt"}
|
||||||
|
{...formValues.getInputProps(`fields.${index}.validator.maxLength`)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
classNames={{
|
||||||
|
input: "monospace"
|
||||||
|
}}
|
||||||
|
label={"Regex"}
|
||||||
|
placeholder={"/.*/gm"}
|
||||||
|
{...formValues.getInputProps(`fields.${index}.validator.regex`)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation settings for date fields
|
||||||
|
* Min date and max date
|
||||||
|
*/
|
||||||
|
const DateFieldSettings = ({formValues, index}: Omit<EditFieldProps, "field">) => {
|
||||||
|
return <>
|
||||||
|
<DateTimePicker
|
||||||
|
label={"Minimales Datum"}
|
||||||
|
placeholder={"unbegrenzt"}
|
||||||
|
{...formValues.getInputProps(`fields.${index}.validator.minDate`)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DateTimePicker
|
||||||
|
label={"Maximales Datum"}
|
||||||
|
placeholder={"unbegrenzt"}
|
||||||
|
{...formValues.getInputProps(`fields.${index}.validator.maxDate`)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation settings for number fields
|
||||||
|
* Min number and max number
|
||||||
|
*/
|
||||||
|
const NumberFieldSettings = ({formValues, index}: Omit<EditFieldProps, "field">) => {
|
||||||
|
return <>
|
||||||
|
<NumberInput
|
||||||
|
label={"Minimale Zahl"}
|
||||||
|
placeholder={"unbegrenzt"}
|
||||||
|
{...formValues.getInputProps(`fields.${index}.validator.min`)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label={"Maximale Zahl"}
|
||||||
|
placeholder={"unbegrenzt"}
|
||||||
|
{...formValues.getInputProps(`fields.${index}.validator.max`)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings for select fields
|
||||||
|
* List to add and remove options
|
||||||
|
*/
|
||||||
|
const SelectFieldSettings = ({field, formValues, index}: Omit<EditFieldProps, "field"> & { field: SelectField }) => {
|
||||||
|
|
||||||
|
const error = formValues.errors?.[`fields.${index}.options`]
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Fieldset legend={"Auswahloptionen"} className={"stack"}>
|
||||||
|
|
||||||
|
{error && <Text size={"sm"} c={"red"}>{error}</Text>}
|
||||||
|
|
||||||
|
{field.options.map((_, optionIndex: number) => {
|
||||||
|
return <TextInput
|
||||||
|
key={optionIndex}
|
||||||
|
w={"100%"}
|
||||||
|
variant="filled"
|
||||||
|
placeholder={"Option"}
|
||||||
|
|
||||||
|
rightSection={
|
||||||
|
|
||||||
|
<ActionIcon
|
||||||
|
color={"red"}
|
||||||
|
variant={"transparent"}
|
||||||
|
aria-label={"delete option"}
|
||||||
|
onClick={() => {
|
||||||
|
formValues.removeListItem(`fields.${index}.options`, optionIndex)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconX/>
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
{...formValues.getInputProps(`fields.${index}.options.${optionIndex}`)}
|
||||||
|
error={!!error}
|
||||||
|
/>
|
||||||
|
})}
|
||||||
|
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
variant={"light"} size="xs"
|
||||||
|
leftSection={<IconPlus/>}
|
||||||
|
onClick={() => {
|
||||||
|
formValues.insertListItem(`fields.${index}.options`, "")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Option hinzufügen
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Fieldset>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings for email fields
|
||||||
|
* List to add and remove allowed domains
|
||||||
|
*/
|
||||||
|
const EmailFieldSettings = ({field, formValues, index}: Omit<EditFieldProps, "field"> & { field: EmailField }) => {
|
||||||
|
return <>
|
||||||
|
<Fieldset legend={"Erlaubte Email Domain's"} className={"stack"}>
|
||||||
|
|
||||||
|
{
|
||||||
|
(field.validator?.allowedDomains || []).length == 0 && (
|
||||||
|
<Text size={"sm"} c={"green"}>
|
||||||
|
Alle Email Adressen sind erlaubt
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{(field.validator?.allowedDomains ?? []).map((_, optionIndex: number) => {
|
||||||
|
return <TextInput
|
||||||
|
key={optionIndex}
|
||||||
|
w={"100%"}
|
||||||
|
variant="filled"
|
||||||
|
placeholder={"example.com"}
|
||||||
|
|
||||||
|
rightSection={
|
||||||
|
<ActionIcon
|
||||||
|
color={"red"}
|
||||||
|
variant={"transparent"}
|
||||||
|
aria-label={"delete option"}
|
||||||
|
onClick={() => {
|
||||||
|
formValues.removeListItem(`fields.${index}.validator.allowedDomains`, optionIndex)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconX/>
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
{...formValues.getInputProps(`fields.${index}.validator.allowedDomains.${optionIndex}`)}
|
||||||
|
/>
|
||||||
|
})}
|
||||||
|
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
variant={"light"} size="xs"
|
||||||
|
leftSection={<IconPlus/>}
|
||||||
|
onClick={() => {
|
||||||
|
formValues.insertListItem(`fields.${index}.validator.allowedDomains`, "")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Domain hinzufügen
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Fieldset>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This component lets the user edit a field in the form builder
|
||||||
|
* @param field The field to edit
|
||||||
|
* @param formValues The uncontrolled form values
|
||||||
|
* @param index The index of the field in the form
|
||||||
|
*/
|
||||||
|
export default function EditField({field, formValues, index}: EditFieldProps) {
|
||||||
|
|
||||||
|
// check if any errors are present for this field or its children
|
||||||
|
const hasError = formValues.errors && Object.keys(formValues.errors).some(key => key.startsWith(`fields.${index}`))
|
||||||
|
|
||||||
|
// toggle the expanded settings view
|
||||||
|
const [showSettings, sowSettingsHandler] = useDisclosure(false)
|
||||||
|
|
||||||
|
return <Draggable key={field.id} index={index} draggableId={field.id}>
|
||||||
|
{(provided) => (
|
||||||
|
<div ref={provided.innerRef} {...provided.draggableProps} className={classes.container}
|
||||||
|
data-has-error={hasError}>
|
||||||
|
<div className={classes.header}>
|
||||||
|
<ThemeIcon variant={"transparent"} {...provided.dragHandleProps}>
|
||||||
|
<IconDragDrop/>
|
||||||
|
</ThemeIcon>
|
||||||
|
<TextInput
|
||||||
|
leftSection={
|
||||||
|
<FormTypeIcon size={"1rem"} fieldType={field.type}/>
|
||||||
|
}
|
||||||
|
variant="unstyled"
|
||||||
|
placeholder={"Name"}
|
||||||
|
required
|
||||||
|
disabled={field.meta?.required}
|
||||||
|
{...formValues.getInputProps(`fields.${index}.label`)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Badge color={"gray"} variant="light">
|
||||||
|
{humanReadableField(field.type)}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<ActionIcon
|
||||||
|
variant={"transparent"}
|
||||||
|
onClick={sowSettingsHandler.toggle}
|
||||||
|
color={hasError ? "red" : "blue"}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
showSettings ? <IconSettingsOff/> : <IconSettings/>
|
||||||
|
}
|
||||||
|
</ActionIcon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
showSettings &&
|
||||||
|
<>
|
||||||
|
<Stack>
|
||||||
|
|
||||||
|
{field.meta?.required && (
|
||||||
|
<Alert color={"blue"}>
|
||||||
|
Dieses Feld wird vom System benötigt und kann nicht gelöscht werden.
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
<Group>
|
||||||
|
<TextInput
|
||||||
|
label={"Beschreibung"}
|
||||||
|
{...formValues.getInputProps(`fields.${index}.description`)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label={"Platzhalter"}
|
||||||
|
{...formValues.getInputProps(`fields.${index}.placeholder`)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{
|
||||||
|
// validation settings for text fields
|
||||||
|
(field.type === "text") &&
|
||||||
|
<TextFieldSettings index={index} formValues={formValues}/>
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// validation settings for number fields
|
||||||
|
(field.type === "number") &&
|
||||||
|
<NumberFieldSettings index={index} formValues={formValues}/>
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// validation settings for date fields
|
||||||
|
(field.type === "date") &&
|
||||||
|
<DateFieldSettings index={index} formValues={formValues}/>
|
||||||
|
}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{
|
||||||
|
// settings for select fields
|
||||||
|
(field.type === "select") &&
|
||||||
|
<SelectFieldSettings index={index} field={field} formValues={formValues}/>
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// settings for email fields
|
||||||
|
(field.type === "email") &&
|
||||||
|
<EmailFieldSettings index={index} field={field} formValues={formValues}/>
|
||||||
|
}
|
||||||
|
|
||||||
|
<Group>
|
||||||
|
<Checkbox
|
||||||
|
label={"Pflichtfeld"}
|
||||||
|
{...formValues.getInputProps(`fields.${index}.required`, {type: "checkbox"})}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{
|
||||||
|
field.type === "text" && <>
|
||||||
|
<Checkbox
|
||||||
|
label={"Mehrzeilig"}
|
||||||
|
{...formValues.getInputProps(`fields.${index}.multiline`, {type: "checkbox"})}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
field.type === "select" && <>
|
||||||
|
<Checkbox
|
||||||
|
label={"Mehrfachauswahl"}
|
||||||
|
{...formValues.getInputProps(`fields.${index}.multiple`, {type: "checkbox"})}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
ml={"auto"}
|
||||||
|
variant={"light"} color={"red"} size={"xs"}
|
||||||
|
leftSection={field.meta?.required ? <IconTrashOff/> : <IconTrash/>}
|
||||||
|
disabled={field.meta?.required}
|
||||||
|
onClick={() => {
|
||||||
|
formValues.removeListItem("fields", index)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Feld Löschen
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
|
}
|
|
@ -0,0 +1,123 @@
|
||||||
|
import {FieldTypes, FormSchema, FormSchemaField} from "./types.ts";
|
||||||
|
import {isNotEmpty, useForm} from "@mantine/form";
|
||||||
|
import {createNewField, humanReadableField} from "../util.ts";
|
||||||
|
import {Alert, Button, Group, Menu, Modal} from "@mantine/core";
|
||||||
|
import EditField from "./editField.tsx";
|
||||||
|
import {IconEye, IconPlus} from "@tabler/icons-react";
|
||||||
|
import FormTypeIcon from "../formTypeIcon.tsx";
|
||||||
|
import {useDisclosure} from "@mantine/hooks";
|
||||||
|
import FormInput from "../fromInput";
|
||||||
|
import {DragDropContext, Droppable} from "@hello-pangea/dnd";
|
||||||
|
|
||||||
|
export default function FormBuilder({initialValues, onSubmit, withPreview}: {
|
||||||
|
initialValues?: FormSchema,
|
||||||
|
onSubmit?: (values: FormSchema) => void,
|
||||||
|
withPreview?: boolean
|
||||||
|
}) {
|
||||||
|
|
||||||
|
const [showPreview, setShowPreview] = useDisclosure(false)
|
||||||
|
|
||||||
|
const formValues = useForm({
|
||||||
|
mode: "uncontrolled",
|
||||||
|
initialValues: initialValues ?? {
|
||||||
|
fields: []
|
||||||
|
} as FormSchema,
|
||||||
|
validate: {
|
||||||
|
fields: {
|
||||||
|
// check if label is unique
|
||||||
|
label: (value, values) => values.fields.map(({label}) => label).filter(isNotEmpty).filter((label) => label === value).length > 1 ? "Der Name muss eindeutig sein" : value.length > 0 ? null : "Der Name darf nicht leer sein",
|
||||||
|
|
||||||
|
// check if all options are unique and not empty
|
||||||
|
options: value => {
|
||||||
|
if (value === undefined) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.length === 0) {
|
||||||
|
return "Es muss mindestens eine Option geben"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value?.some((option) => option === "")) {
|
||||||
|
return "Optionen dürfen nicht leer sein"
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUnique = value && (value.length === new Set(value).size)
|
||||||
|
return isUnique ? null : "Alle Optionen müssen eindeutig sein"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={formValues.onSubmit(values => {
|
||||||
|
onSubmit && onSubmit(values)
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
|
||||||
|
<DragDropContext
|
||||||
|
onDragEnd={({destination, source}) =>
|
||||||
|
destination?.index !== undefined && formValues.reorderListItem('fields', {from: source.index, to: destination.index})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Droppable droppableId="dnd-list" direction="vertical">
|
||||||
|
{(provided) => (
|
||||||
|
<div {...provided.droppableProps} ref={provided.innerRef}>
|
||||||
|
{formValues.values.fields.map((field: FormSchemaField, index: number) => {
|
||||||
|
return <EditField key={field.id} index={index} field={field} formValues={formValues}/>
|
||||||
|
})}
|
||||||
|
{provided.placeholder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
</DragDropContext>
|
||||||
|
<Group>
|
||||||
|
<Menu position="bottom-start" withArrow>
|
||||||
|
<Menu.Target>
|
||||||
|
<Button leftSection={<IconPlus/>} variant={"light"}>Feld hinzufügen</Button>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
{
|
||||||
|
FieldTypes.map((fieldType) => {
|
||||||
|
return <Menu.Item
|
||||||
|
leftSection={<FormTypeIcon size={"1rem"} fieldType={fieldType}/>}
|
||||||
|
onClick={() => formValues.insertListItem("fields", createNewField(fieldType))}
|
||||||
|
key={fieldType}
|
||||||
|
>
|
||||||
|
{humanReadableField(fieldType)}
|
||||||
|
</Menu.Item>
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
{
|
||||||
|
withPreview && <>
|
||||||
|
<Modal opened={showPreview} onClose={setShowPreview.close} title={"Vorschau"} size={"lg"}>
|
||||||
|
<div className={`stack`}>
|
||||||
|
<Alert color={"orange"}>
|
||||||
|
Die Vorschau zeigt nur die Struktur des Formulars an. Die Eingaben werden nicht
|
||||||
|
gespeichert.
|
||||||
|
</Alert>
|
||||||
|
<FormInput schema={formValues.values}/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={setShowPreview.toggle}
|
||||||
|
variant={"light"}
|
||||||
|
leftSection={<IconEye/>}
|
||||||
|
>
|
||||||
|
Vorschau
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
<Button type={"submit"}>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,62 @@
|
||||||
|
export type AbstractSchemaField = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
description: string | null
|
||||||
|
required: boolean
|
||||||
|
placeholder: string | null
|
||||||
|
meta?: {
|
||||||
|
required?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TextField = {
|
||||||
|
type: "text"
|
||||||
|
multiline: boolean
|
||||||
|
validator: {
|
||||||
|
minLength: number | null
|
||||||
|
maxLength: number | null
|
||||||
|
regex: string
|
||||||
|
}
|
||||||
|
} & AbstractSchemaField
|
||||||
|
|
||||||
|
export type EmailField = {
|
||||||
|
type: "email",
|
||||||
|
validator: {
|
||||||
|
allowedDomains: string[],
|
||||||
|
}
|
||||||
|
} & AbstractSchemaField
|
||||||
|
|
||||||
|
export type NumberField = {
|
||||||
|
type: "number",
|
||||||
|
validator: {
|
||||||
|
min: number | null
|
||||||
|
max: number | null
|
||||||
|
}
|
||||||
|
} & AbstractSchemaField
|
||||||
|
|
||||||
|
export type DateField = {
|
||||||
|
type: "date"
|
||||||
|
validator: {
|
||||||
|
minDate: Date | null
|
||||||
|
maxDate: Date | null
|
||||||
|
}
|
||||||
|
} & AbstractSchemaField
|
||||||
|
|
||||||
|
export type SelectField = {
|
||||||
|
type: "select",
|
||||||
|
multiple: boolean
|
||||||
|
options: string[]
|
||||||
|
} & AbstractSchemaField
|
||||||
|
|
||||||
|
export type CheckboxField = {
|
||||||
|
type: "checkbox"
|
||||||
|
} & AbstractSchemaField
|
||||||
|
|
||||||
|
export type FormSchemaField = TextField | EmailField | NumberField | DateField | SelectField | CheckboxField
|
||||||
|
|
||||||
|
export type FieldType = "text" | "number" | "date" | "select" | "checkbox" | "email"
|
||||||
|
export const FieldTypes: FieldType[] = ["text", "email", "number", "date", "select", "checkbox"]
|
||||||
|
|
||||||
|
export type FormSchema = {
|
||||||
|
fields: FormSchemaField[],
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
import {FieldType} from "./types.ts";
|
||||||
|
import {
|
||||||
|
IconAt,
|
||||||
|
IconCalendar,
|
||||||
|
IconCheckbox,
|
||||||
|
IconCursorText,
|
||||||
|
IconHash,
|
||||||
|
IconList,
|
||||||
|
TablerIconsProps
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
|
const FormTypeIcon = ({fieldType, ...props}: { fieldType: FieldType } & TablerIconsProps) => {
|
||||||
|
switch (fieldType) {
|
||||||
|
case "text":
|
||||||
|
return <IconCursorText {...props}/>
|
||||||
|
case "email":
|
||||||
|
return <IconAt {...props}/>
|
||||||
|
case "number":
|
||||||
|
return <IconHash {...props}/>
|
||||||
|
case "date":
|
||||||
|
return <IconCalendar {...props}/>
|
||||||
|
case "select":
|
||||||
|
return <IconList{...props}/>
|
||||||
|
case "checkbox":
|
||||||
|
return <IconCheckbox{...props}/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FormTypeIcon;
|
|
@ -0,0 +1,198 @@
|
||||||
|
import {
|
||||||
|
CheckboxField,
|
||||||
|
DateField,
|
||||||
|
EmailField,
|
||||||
|
FormSchema,
|
||||||
|
FormSchemaField,
|
||||||
|
NumberField,
|
||||||
|
SelectField,
|
||||||
|
TextField
|
||||||
|
} from "../formBuilder/types.ts";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
CheckboxProps,
|
||||||
|
Group,
|
||||||
|
MultiSelect,
|
||||||
|
MultiSelectProps,
|
||||||
|
NumberInput,
|
||||||
|
NumberInputProps,
|
||||||
|
Select,
|
||||||
|
SelectProps,
|
||||||
|
Textarea,
|
||||||
|
TextareaProps,
|
||||||
|
TextInput,
|
||||||
|
TextInputProps,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {IconAt} from "@tabler/icons-react";
|
||||||
|
import {DateTimePicker, DateTimePickerProps} from "@mantine/dates";
|
||||||
|
import {useForm} from "@mantine/form";
|
||||||
|
import {FormEntries} from "./types.ts";
|
||||||
|
import {useEffect} from "react";
|
||||||
|
|
||||||
|
|
||||||
|
const unpackField = (field: FormSchemaField) => {
|
||||||
|
return {
|
||||||
|
label: field.label || undefined,
|
||||||
|
placeholder: field.placeholder || undefined,
|
||||||
|
description: field.description || undefined,
|
||||||
|
required: field.required || false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormTextField = ({field, ...props}: { field: TextField } & TextInputProps) => {
|
||||||
|
if (field.multiline) {
|
||||||
|
console.error("for multiline text fields use FormTextareaField instead of FormTextField")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return <TextInput
|
||||||
|
{...unpackField(field)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormTextareaField = ({field, ...props}: { field: TextField } & TextareaProps) => {
|
||||||
|
if (!field.multiline) {
|
||||||
|
console.error("for single line text fields use FormTextField instead of FormTextareaField")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return <Textarea
|
||||||
|
resize={"vertical"}
|
||||||
|
{...unpackField(field)}
|
||||||
|
{...props}/>
|
||||||
|
}
|
||||||
|
|
||||||
|
const EmailField = ({field, ...props}: { field: EmailField } & TextInputProps) => {
|
||||||
|
return <TextInput
|
||||||
|
leftSection={<IconAt/>}
|
||||||
|
{...unpackField(field)}
|
||||||
|
type={"email"}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
|
const NumberField = ({field, ...props}: { field: NumberField } & NumberInputProps) => {
|
||||||
|
return <NumberInput
|
||||||
|
{...unpackField(field)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
|
const DateField = ({field, ...props}: { field: DateField } & DateTimePickerProps) => {
|
||||||
|
return <DateTimePicker
|
||||||
|
clearable
|
||||||
|
{...unpackField(field)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
|
const SelectField = ({field, ...props}: { field: SelectField } & SelectProps & MultiSelectProps) => {
|
||||||
|
|
||||||
|
const Component = field.multiple ? MultiSelect : Select
|
||||||
|
|
||||||
|
return <Component
|
||||||
|
clearable
|
||||||
|
{...unpackField(field)}
|
||||||
|
{...props}
|
||||||
|
data={field.options}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
|
const CheckboxField = ({field, ...props}: { field: CheckboxField } & CheckboxProps) => {
|
||||||
|
return <Checkbox
|
||||||
|
{...unpackField(field)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
|
const createDefaultFormEntries = (schema: FormSchema): FormEntries => {
|
||||||
|
const entries: FormEntries = {}
|
||||||
|
schema.fields.forEach(field => {
|
||||||
|
const defaultValue =
|
||||||
|
field.type === "text" ? "" :
|
||||||
|
field.type === "email" ? "" :
|
||||||
|
field.type === "number" ? "" :
|
||||||
|
field.type === "checkbox" ? false :
|
||||||
|
field.type === "select" && field.multiple ? [] :
|
||||||
|
field.type === "select" && !field.multiple ? null :
|
||||||
|
field.type === "date" ? null : null
|
||||||
|
|
||||||
|
entries[field.label] = {
|
||||||
|
value: defaultValue,
|
||||||
|
type: field.type,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FormInput({schema}: { schema: FormSchema }) {
|
||||||
|
|
||||||
|
const formValues = useForm({
|
||||||
|
mode: "controlled", // todo change to uncontrolled
|
||||||
|
// todo validation (don't forget checkbox required validation)
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const data = createDefaultFormEntries(schema)
|
||||||
|
formValues.setInitialValues(data)
|
||||||
|
formValues.setValues(data)
|
||||||
|
console.log("initial values set", formValues.values)
|
||||||
|
},
|
||||||
|
[schema])
|
||||||
|
|
||||||
|
return <form
|
||||||
|
className={"stack"}
|
||||||
|
onSubmit={formValues.onSubmit((values) => {
|
||||||
|
console.log(values)
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{schema.fields.map(field => {
|
||||||
|
let Component = null
|
||||||
|
|
||||||
|
switch (field.type) {
|
||||||
|
case "text":
|
||||||
|
Component = field.multiline ? FormTextareaField : FormTextField
|
||||||
|
break
|
||||||
|
|
||||||
|
case "email":
|
||||||
|
Component = EmailField
|
||||||
|
break
|
||||||
|
|
||||||
|
case "number":
|
||||||
|
Component = NumberField
|
||||||
|
break
|
||||||
|
|
||||||
|
case "date":
|
||||||
|
Component = DateField
|
||||||
|
break
|
||||||
|
|
||||||
|
case "select":
|
||||||
|
Component = SelectField
|
||||||
|
break
|
||||||
|
|
||||||
|
case "checkbox":
|
||||||
|
Component = CheckboxField
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return <Component
|
||||||
|
key={field.id}
|
||||||
|
field={field as never}
|
||||||
|
{...formValues.getInputProps(`${field.label}.value`)}
|
||||||
|
/>
|
||||||
|
})}
|
||||||
|
|
||||||
|
<Group>
|
||||||
|
<Button onClick={formValues.reset}>
|
||||||
|
Zurücksetzen
|
||||||
|
</Button>
|
||||||
|
<Button type={"submit"}>Speichern</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<pre>
|
||||||
|
{JSON.stringify(formValues.values, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</form>
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
import {FieldType} from "../formBuilder/types.ts";
|
||||||
|
|
||||||
|
export type FieldEntry = {
|
||||||
|
value: unknown
|
||||||
|
type: FieldType
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FormEntries = {
|
||||||
|
[key: string]: FieldEntry
|
||||||
|
}
|
|
@ -0,0 +1,85 @@
|
||||||
|
import {randomId} from "@mantine/hooks";
|
||||||
|
import {FieldType, FormSchemaField} from "./formBuilder/types.ts";
|
||||||
|
|
||||||
|
export const humanReadableField = (fieldType: FieldType): string => {
|
||||||
|
switch (fieldType) {
|
||||||
|
case "text":
|
||||||
|
return "Text"
|
||||||
|
case "email":
|
||||||
|
return "Email"
|
||||||
|
case "number":
|
||||||
|
return "Zahl"
|
||||||
|
case "date":
|
||||||
|
return "Datum & Zeit"
|
||||||
|
case "select":
|
||||||
|
return "(Mehrfach-) Auswahl"
|
||||||
|
case "checkbox":
|
||||||
|
return "Checkbox"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createNewField = (fieldType: FieldType): FormSchemaField => {
|
||||||
|
|
||||||
|
const abstractField = {
|
||||||
|
id: randomId(),
|
||||||
|
label: "",
|
||||||
|
description: "",
|
||||||
|
placeholder: "",
|
||||||
|
required: false,
|
||||||
|
meta: {
|
||||||
|
required: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (fieldType) {
|
||||||
|
case "text":
|
||||||
|
return {
|
||||||
|
type: "text",
|
||||||
|
multiline: false,
|
||||||
|
validator: {
|
||||||
|
minLength: null,
|
||||||
|
maxLength: null,
|
||||||
|
regex: ""
|
||||||
|
},
|
||||||
|
...abstractField,
|
||||||
|
}
|
||||||
|
case "email":
|
||||||
|
return {
|
||||||
|
type: "email",
|
||||||
|
validator: {
|
||||||
|
allowedDomains: []
|
||||||
|
},
|
||||||
|
...abstractField,
|
||||||
|
}
|
||||||
|
case "number":
|
||||||
|
return {
|
||||||
|
type: "number",
|
||||||
|
validator: {
|
||||||
|
min: null,
|
||||||
|
max: null,
|
||||||
|
},
|
||||||
|
...abstractField,
|
||||||
|
}
|
||||||
|
case "date":
|
||||||
|
return {
|
||||||
|
type: "date",
|
||||||
|
validator: {
|
||||||
|
minDate: null,
|
||||||
|
maxDate: null,
|
||||||
|
},
|
||||||
|
...abstractField,
|
||||||
|
}
|
||||||
|
case "select":
|
||||||
|
return {
|
||||||
|
type: "select",
|
||||||
|
multiple: false,
|
||||||
|
options: [],
|
||||||
|
...abstractField,
|
||||||
|
}
|
||||||
|
case "checkbox":
|
||||||
|
return {
|
||||||
|
type: "checkbox",
|
||||||
|
...abstractField,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
.container {
|
||||||
|
border: 1px solid var(--mantine-color-gray-4);
|
||||||
|
|
||||||
|
&[data-margin="true"] {
|
||||||
|
margin-top: calc(var(--mantine-spacing-xs) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-error="true"] {
|
||||||
|
margin-bottom: calc(var(--mantine-spacing-xs) / 2);
|
||||||
|
border-color: var(--mantine-color-red-7);
|
||||||
|
color: var(--mantine-color-red-7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.noBorder {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
font-size: var(--mantine-font-size-sm);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: var(--mantine-spacing-md);
|
||||||
|
padding: var(--mantine-spacing-xs);
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background-color: unset;
|
||||||
|
|
||||||
|
@media (max-width: $mantine-breakpoint-xs) {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,141 @@
|
||||||
|
import {BubbleMenu, Editor, useEditor} from "@tiptap/react";
|
||||||
|
import {StarterKit} from "@tiptap/starter-kit";
|
||||||
|
import {Underline} from "@tiptap/extension-underline";
|
||||||
|
import Placeholder from '@tiptap/extension-placeholder';
|
||||||
|
import {RichTextEditor, RichTextEditorContent} from "@mantine/tiptap";
|
||||||
|
import classes from './index.module.css';
|
||||||
|
import {Box, Input, InputWrapperProps, Loader} from "@mantine/core";
|
||||||
|
import {useEffect} from "react";
|
||||||
|
import sanitizeHtml from 'sanitize-html';
|
||||||
|
|
||||||
|
|
||||||
|
const Bubble = ({editor}: { editor: Editor }) => (
|
||||||
|
<BubbleMenu editor={editor}>
|
||||||
|
<RichTextEditor.ControlsGroup>
|
||||||
|
<RichTextEditor.Bold/>
|
||||||
|
<RichTextEditor.Italic/>
|
||||||
|
<RichTextEditor.Link/>
|
||||||
|
</RichTextEditor.ControlsGroup>
|
||||||
|
</BubbleMenu>
|
||||||
|
)
|
||||||
|
|
||||||
|
const Toolbar = ({fullToolbar}: { fullToolbar: boolean, editor: Editor }) => (
|
||||||
|
<RichTextEditor.Toolbar>
|
||||||
|
{
|
||||||
|
fullToolbar ?
|
||||||
|
<>
|
||||||
|
<RichTextEditor.ControlsGroup>
|
||||||
|
<RichTextEditor.Bold/>
|
||||||
|
<RichTextEditor.Italic/>
|
||||||
|
<RichTextEditor.Underline/>
|
||||||
|
<RichTextEditor.Code/>
|
||||||
|
<RichTextEditor.Strikethrough/>
|
||||||
|
</RichTextEditor.ControlsGroup>
|
||||||
|
|
||||||
|
<RichTextEditor.ControlsGroup>
|
||||||
|
<RichTextEditor.H1/>
|
||||||
|
<RichTextEditor.H2/>
|
||||||
|
<RichTextEditor.H3/>
|
||||||
|
<RichTextEditor.H4/>
|
||||||
|
</RichTextEditor.ControlsGroup>
|
||||||
|
|
||||||
|
<RichTextEditor.ControlsGroup>
|
||||||
|
<RichTextEditor.Blockquote/>
|
||||||
|
<RichTextEditor.Hr/>
|
||||||
|
<RichTextEditor.BulletList/>
|
||||||
|
<RichTextEditor.OrderedList/>
|
||||||
|
</RichTextEditor.ControlsGroup>
|
||||||
|
</>
|
||||||
|
:
|
||||||
|
<>
|
||||||
|
<RichTextEditor.ControlsGroup>
|
||||||
|
<RichTextEditor.Bold/>
|
||||||
|
<RichTextEditor.Italic/>
|
||||||
|
<RichTextEditor.Underline/>
|
||||||
|
<RichTextEditor.Code/>
|
||||||
|
</RichTextEditor.ControlsGroup>
|
||||||
|
</>
|
||||||
|
|
||||||
|
}
|
||||||
|
</RichTextEditor.Toolbar>
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A wrapper around the Mantine Input component that provides a WYSIWYG editor.
|
||||||
|
* @param value The value of the editor.
|
||||||
|
* @param onChange The callback to call when the editor's value changes.
|
||||||
|
* @param placeholder The placeholder text to show when the editor is empty.
|
||||||
|
* @param fullToolbar Whether to show the full toolbar or not.
|
||||||
|
* @param maxHeight The maximum height of the editor.
|
||||||
|
* @param hideToolbar Whether to hide the toolbar or not. If hidden a bubble menu will be shown instead.
|
||||||
|
* @param noBorder shows no border if true
|
||||||
|
* @param props The props to pass to the Mantine Input Wrapper component.
|
||||||
|
*/
|
||||||
|
export default function TextEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
fullToolbar,
|
||||||
|
maxHeight,
|
||||||
|
hideToolbar,
|
||||||
|
noBorder,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
fullToolbar?: boolean;
|
||||||
|
maxHeight?: number | string;
|
||||||
|
hideToolbar?: boolean;
|
||||||
|
noBorder?: boolean;
|
||||||
|
} & Omit<InputWrapperProps, "onChange">) {
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit,
|
||||||
|
Underline,
|
||||||
|
Placeholder.configure({placeholder})
|
||||||
|
],
|
||||||
|
content: value,
|
||||||
|
onUpdate: ({editor}) => {
|
||||||
|
const cleanHtml = sanitizeHtml(editor.getHTML())
|
||||||
|
const tempElement = document.createElement('div')
|
||||||
|
tempElement.innerHTML = cleanHtml
|
||||||
|
if (tempElement.textContent === '') {
|
||||||
|
onChange('')
|
||||||
|
} else {
|
||||||
|
onChange(cleanHtml)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return
|
||||||
|
if (editor.getHTML() === value) return
|
||||||
|
const {from, to} = editor.state.selection
|
||||||
|
editor.commands.setContent(value, false)
|
||||||
|
editor.commands.setTextSelection({from, to})
|
||||||
|
}, [editor, value])
|
||||||
|
|
||||||
|
if (!editor) {
|
||||||
|
return <Loader size={"xs"}/>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input.Wrapper miw={0} maw={"100%"} {...props}>
|
||||||
|
<Box
|
||||||
|
component={RichTextEditor}
|
||||||
|
editor={editor}
|
||||||
|
mod={{error: !!props.error, margin: !!props.label || !!props.description}}
|
||||||
|
classNames={{
|
||||||
|
content: `${classes.content} scrollbar`,
|
||||||
|
root: `${classes.container} ${noBorder ? classes.noBorder : ''}`,
|
||||||
|
toolbar: classes.toolbar,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RichTextEditorContent mah={maxHeight ?? "100px"}/>
|
||||||
|
{hideToolbar ? <Bubble editor={editor}/> : <Toolbar editor={editor} fullToolbar={!!fullToolbar}/>}
|
||||||
|
</Box>
|
||||||
|
</Input.Wrapper>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,90 @@
|
||||||
|
import {useDropzone} from "react-dropzone";
|
||||||
|
import {Box, BoxProps, Center, InputWrapperProps, Stack, Text, Title} from "@mantine/core";
|
||||||
|
import {IconPhoto, IconPhotoEdit, IconPhotoUp, IconPhotoX} from "@tabler/icons-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This component allows the user to select images by dragging and dropping them into the component.
|
||||||
|
* Images can also be selected by clicking on the component.
|
||||||
|
*
|
||||||
|
* This component allows the mime types 'image/jpeg', 'image/png' and 'image/gif'.
|
||||||
|
*
|
||||||
|
* @param error - An error message to display to the user.
|
||||||
|
* @param onChange - A function that is called when the user selects images.
|
||||||
|
* @param fileCount - The number of files that have been selected.
|
||||||
|
* @param maxFileCount - The maximum number of files that can be selected.
|
||||||
|
* @param small - A boolean that determines if the component should be displayed in a smaller size.
|
||||||
|
* @param props - Additional props to pass to the component.
|
||||||
|
*/
|
||||||
|
export default function ImageSelect({
|
||||||
|
error,
|
||||||
|
onChange,
|
||||||
|
fileCount,
|
||||||
|
maxFileCount,
|
||||||
|
small,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
small?: boolean
|
||||||
|
onChange: (files: File[]) => void, fileCount: number, maxFileCount: number
|
||||||
|
}
|
||||||
|
& Omit<BoxProps, "children" | "style" | "className">
|
||||||
|
& Pick<InputWrapperProps, "error">
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
getRootProps,
|
||||||
|
getInputProps,
|
||||||
|
isDragActive,
|
||||||
|
isDragAccept,
|
||||||
|
isDragReject
|
||||||
|
} = useDropzone({
|
||||||
|
accept: {
|
||||||
|
'image/jpeg': [],
|
||||||
|
'image/png': [],
|
||||||
|
'image/gif': [],
|
||||||
|
},
|
||||||
|
maxSize: 5232880,
|
||||||
|
onDropAccepted: (files) => onChange(files),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box
|
||||||
|
className="container"
|
||||||
|
style={(theme) => ({
|
||||||
|
height: "100%",
|
||||||
|
border: `1px dashed ${theme.colors.gray[5]}`,
|
||||||
|
borderColor: isDragAccept ? theme.colors.blue[5] : isDragReject ? theme.colors.red[5] : theme.colors.gray[5],
|
||||||
|
padding: theme.spacing.md,
|
||||||
|
borderRadius: theme.radius.md,
|
||||||
|
cursor: "pointer",
|
||||||
|
"&:hover": {
|
||||||
|
boxShadow: theme.shadows.sm,
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Center{...getRootProps({className: "dropzone"})} style={{height: "100%",}}>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
<Stack align="center">
|
||||||
|
{isDragAccept && <IconPhotoUp/>}
|
||||||
|
{isDragReject && <IconPhotoX/>}
|
||||||
|
{!isDragActive && (small ? <IconPhotoEdit/> : <IconPhoto/>)}
|
||||||
|
|
||||||
|
{!small && <>
|
||||||
|
< Title c={"green"}
|
||||||
|
order={3}>{maxFileCount > 1 ? "Bilder" : "Bild"} hochladen</Title>
|
||||||
|
<Text size={"sm"}>Bilder können per Drag & Drop oder durch {
|
||||||
|
<Text span c={"green"}>klicken</Text>
|
||||||
|
} hochgeladen werden.</Text>
|
||||||
|
{maxFileCount > 1 &&
|
||||||
|
<Text size={"sm"} c={"dimmed"}>{fileCount} von {maxFileCount} Bildern</Text>
|
||||||
|
}
|
||||||
|
<Text size={"sm"} c={"dimmed"}>Maximale Dateigröße: 5MB - jpg/png/gif</Text>
|
||||||
|
{error && <Text size={"sm"} c={"red"}>{error}</Text>}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,137 @@
|
||||||
|
import {RecordModel} from "pocketbase";
|
||||||
|
import {UseMutationResult} from "@tanstack/react-query";
|
||||||
|
import {CheckIcon, Combobox, Group, Pill, PillsInput, PillsInputProps, Stack, Text, useCombobox} from "@mantine/core";
|
||||||
|
import {useState} from "react";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* GenericRecordInputProps is a generic type that describes the props that are common to all Record Input Wrappers
|
||||||
|
* @param T - The type of the record that is being selected
|
||||||
|
* @param selectedRecords - The records that are currently selected
|
||||||
|
* @param setSelectedRecords - A function that sets the selected records
|
||||||
|
* @param label - The label of the input field
|
||||||
|
* @param description - The description of the input field
|
||||||
|
* @param leftSection - The icon that is displayed on the left side of the input field
|
||||||
|
* @param placeholder - The placeholder text of the input field
|
||||||
|
*/
|
||||||
|
export type GenericRecordSearchInputProps<T> = {
|
||||||
|
selectedRecords: T[]
|
||||||
|
setSelectedRecords: (records: T[]) => void
|
||||||
|
} & Pick<PillsInputProps, "label" | "description" | "leftSection" | "placeholder" | "required" | "error">
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RecordSearchInput is a generic component that can be used to create a searchable input field for selecting records from a database.
|
||||||
|
* @param props - The props for the RecordSearchInput component
|
||||||
|
* @param props.recordToString - A function that converts a record to a display name and description
|
||||||
|
* @param props.recordSearchMutation - A mutation that fetches records from the database based on a search string. It is advised to use the useMutation hook from react-query.
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
export default function RecordSearchInput<T extends RecordModel>(props: {
|
||||||
|
recordToString: (record: T) => { displayName: string, description?: string }
|
||||||
|
recordSearchMutation: UseMutationResult<T[], Error, string, unknown>
|
||||||
|
} & GenericRecordSearchInputProps<T>) {
|
||||||
|
|
||||||
|
const combobox = useCombobox({
|
||||||
|
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||||
|
onDropdownOpen: () => combobox.updateSelectedOptionIndex('active'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
|
||||||
|
const selectedRecordIds = props.selectedRecords.map((record) => record.id)
|
||||||
|
|
||||||
|
const handleValueSelect = (val: string) => {
|
||||||
|
if (selectedRecordIds.includes(val)) {
|
||||||
|
return handleValueRemove(val)
|
||||||
|
}
|
||||||
|
props.setSelectedRecords(
|
||||||
|
[
|
||||||
|
...props.selectedRecords,
|
||||||
|
props.recordSearchMutation.data?.find((record) => record.id === val)
|
||||||
|
].filter(record => record !== undefined) as T[]
|
||||||
|
)
|
||||||
|
setSearch('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleValueRemove = (val: string) => {
|
||||||
|
props.setSelectedRecords(props.selectedRecords.filter((record) => record.id !== val))
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchResults = (props.recordSearchMutation.data || [])
|
||||||
|
.map((recordView) => (
|
||||||
|
<Combobox.Option value={recordView.id} key={recordView.id}
|
||||||
|
active={selectedRecordIds.includes(recordView.id)}>
|
||||||
|
<Group gap="sm" wrap={"nowrap"}>
|
||||||
|
{selectedRecordIds.includes(recordView.id) ? <CheckIcon size={12}/> : null}
|
||||||
|
|
||||||
|
{props.recordToString(recordView).description ? <>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text span size="sm">{props.recordToString(recordView).displayName}</Text>
|
||||||
|
<Text lineClamp={1} span c={"dimmed"}
|
||||||
|
size="xs">{props.recordToString(recordView).description}</Text>
|
||||||
|
</Stack>
|
||||||
|
</> : <>
|
||||||
|
<Text span c={"dimmed"} size="sm">{props.recordToString(recordView).displayName}</Text>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</Group>
|
||||||
|
</Combobox.Option>
|
||||||
|
))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Combobox store={combobox} onOptionSubmit={handleValueSelect}>
|
||||||
|
<Combobox.DropdownTarget>
|
||||||
|
<PillsInput
|
||||||
|
label={props.label}
|
||||||
|
description={props.description}
|
||||||
|
onClick={() => combobox.openDropdown()}
|
||||||
|
leftSection={props.leftSection}
|
||||||
|
required={props.required}
|
||||||
|
error={props.error}
|
||||||
|
>
|
||||||
|
<Pill.Group>
|
||||||
|
{
|
||||||
|
props.selectedRecords.map((selectedRecord) => (
|
||||||
|
<Pill
|
||||||
|
key={selectedRecord.id}
|
||||||
|
withRemoveButton
|
||||||
|
onRemove={() => handleValueRemove(selectedRecord.id)}
|
||||||
|
>
|
||||||
|
{props.recordToString(selectedRecord).displayName}
|
||||||
|
</Pill>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
<Combobox.EventsTarget>
|
||||||
|
<PillsInput.Field
|
||||||
|
onFocus={() => combobox.openDropdown()}
|
||||||
|
onBlur={() => combobox.closeDropdown()}
|
||||||
|
value={search}
|
||||||
|
placeholder={props.placeholder}
|
||||||
|
onChange={(event) => {
|
||||||
|
combobox.updateSelectedOptionIndex()
|
||||||
|
setSearch(event.currentTarget.value)
|
||||||
|
props.recordSearchMutation.mutate(event.currentTarget.value)
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Backspace' && search.length === 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
handleValueRemove(props.selectedRecords[props.selectedRecords.length - 1].id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Combobox.EventsTarget>
|
||||||
|
</Pill.Group>
|
||||||
|
</PillsInput>
|
||||||
|
</Combobox.DropdownTarget>
|
||||||
|
|
||||||
|
<Combobox.Dropdown>
|
||||||
|
<Combobox.Options>
|
||||||
|
{searchResults.length > 0 ? searchResults :
|
||||||
|
<Combobox.Empty>
|
||||||
|
{props.recordSearchMutation.isPending ? 'Lade...' : 'Keine Ergebnisse ...'}
|
||||||
|
</Combobox.Empty>}
|
||||||
|
</Combobox.Options>
|
||||||
|
</Combobox.Dropdown>
|
||||||
|
</Combobox>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,72 @@
|
||||||
|
.footer {
|
||||||
|
padding: var(--padding) var(--mantine-spacing-xl);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: var(--gap);
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: var(--mantine-color-text);
|
||||||
|
font-size: var(--mantine-font-size-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.hideMobile {
|
||||||
|
@media (max-width: $mantine-breakpoint-sm) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.links {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--gap);
|
||||||
|
|
||||||
|
@media (max-width: $mantine-breakpoint-sm) {
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > a, p {
|
||||||
|
color: var(--mantine-color-dimmed) !important;
|
||||||
|
font-size: var(--mantine-font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
& > a {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
@media (max-width: $mantine-breakpoint-sm) {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rights {
|
||||||
|
color: var(--mantine-color-dimmed);
|
||||||
|
font-size: var(--mantine-font-size-sm);
|
||||||
|
|
||||||
|
& > span {
|
||||||
|
[data-apiishealthy="true"] {
|
||||||
|
color: var(--mantine-color-red-filled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,74 @@
|
||||||
|
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 {usePB} from "../../../lib/pocketbase.tsx";
|
||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
const {apiIsHealthy} = usePB()
|
||||||
|
return (
|
||||||
|
<div className={classes.footer}>
|
||||||
|
<div className={classes.inner}>
|
||||||
|
<div className={classes.logo}>
|
||||||
|
<Image
|
||||||
|
h={15}
|
||||||
|
w={15}
|
||||||
|
src={"/public/stuve-logo.svg"}
|
||||||
|
alt={"StuVe IT Logo"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={classes.title}>{APP_NAME}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`${classes.links} ${classes.hideMobile}`}>
|
||||||
|
<h5>Über</h5>
|
||||||
|
|
||||||
|
<p>Version {APP_VERSION}</p>
|
||||||
|
|
||||||
|
<p>Entwickelt vom StuVe Computer Referat</p>
|
||||||
|
|
||||||
|
<Anchor href={"https://git.stuve.uni-ulm.de/stuve-it/stuve-it-frontend"}>Source Code</Anchor>
|
||||||
|
|
||||||
|
<Anchor href={"https://git.stuve.uni-ulm.de/stuve-it/stuve-it-frontend/issues"}>Issues</Anchor>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`${classes.links} ${classes.hideMobile}`}>
|
||||||
|
<h5>Made with</h5>
|
||||||
|
|
||||||
|
<Anchor href={"https://mantine.dev/"}>Mantine</Anchor>
|
||||||
|
|
||||||
|
<Anchor href={"https://tanstack.com/"}>React Query</Anchor>
|
||||||
|
|
||||||
|
<Anchor href={"https://pocketbase.dev/"}>PocketBase</Anchor>
|
||||||
|
|
||||||
|
<Anchor
|
||||||
|
href={"https://git.stuve.uni-ulm.de/stuve-it/stuve-it-frontend/src/branch/main/package.json"}>...
|
||||||
|
and much more</Anchor>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* only these links will show on mobile */}
|
||||||
|
<div className={classes.links}>
|
||||||
|
<h5 className={classes.hideMobile}>Rechtliches</h5>
|
||||||
|
|
||||||
|
<Link to={"/terms-and-conditions"}>AGB der StuVe</Link>
|
||||||
|
|
||||||
|
<Link to={"/privacy-policy"}>Datenschutzerklärung</Link>
|
||||||
|
|
||||||
|
<Link to={"/imprint"}>Impressum</Link>
|
||||||
|
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -5,10 +5,28 @@
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
/*noinspection CssInvalidFunction*/
|
||||||
|
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-9));
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
overflow: auto;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 var(--gap);
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
padding: var(--padding);
|
padding-top: var(--gap);
|
||||||
overflow: auto;
|
|
||||||
height: 100%;
|
& > * {
|
||||||
}
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > :not(:last-child) {
|
||||||
|
margin-bottom: var(--gap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,14 +1,18 @@
|
||||||
import NavBar from "../nav";
|
import NavBar from "./nav";
|
||||||
import {Outlet} from "react-router-dom";
|
import {Outlet} from "react-router-dom";
|
||||||
|
|
||||||
import classes from "./index.module.css";
|
import classes from "./index.module.css";
|
||||||
|
import Footer from "./footer";
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
return <div className={classes.container}>
|
return <div className={classes.container}>
|
||||||
<NavBar/>
|
<NavBar/>
|
||||||
|
|
||||||
<div className={`${classes.content} scrollbar`}>
|
<div className={`${classes.body} no-scrollbar`}>
|
||||||
<Outlet/>
|
<div className={`${classes.content}`}>
|
||||||
|
<Outlet/>
|
||||||
|
</div>
|
||||||
|
<Footer/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
|
@ -1,10 +1,11 @@
|
||||||
import {IconExclamationCircle, IconLogin} from "@tabler/icons-react";
|
import {IconExclamationCircle, IconLogin} from "@tabler/icons-react";
|
||||||
import {ActionIcon, Alert, Button, Modal, PasswordInput, TextInput, Title} from "@mantine/core";
|
import {ActionIcon, Alert, Anchor, Button, Checkbox, Modal, PasswordInput, Text, TextInput, Title} from "@mantine/core";
|
||||||
import {useDisclosure} from "@mantine/hooks";
|
import {useDisclosure} from "@mantine/hooks";
|
||||||
import {usePB} from "../../lib/pocketbase.tsx";
|
import {usePB} from "../../../lib/pocketbase.tsx";
|
||||||
import {useForm} from "@mantine/form";
|
import {useForm} from "@mantine/form";
|
||||||
import {useMutation} from "@tanstack/react-query";
|
import {useMutation} from "@tanstack/react-query";
|
||||||
import classes from "./index.module.css";
|
import classes from "./index.module.css";
|
||||||
|
import {Link} from "react-router-dom";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This component renders a login button and a login modal.
|
* This component renders a login button and a login modal.
|
||||||
|
@ -17,7 +18,8 @@ export default function Login() {
|
||||||
const formValues = useForm({
|
const formValues = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
username: "",
|
username: "",
|
||||||
password: ""
|
password: "",
|
||||||
|
privacy: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -33,8 +35,7 @@ export default function Login() {
|
||||||
className={classes.stack}
|
className={classes.stack}
|
||||||
onSubmit={formValues.onSubmit(() => loginMutation.mutate())}
|
onSubmit={formValues.onSubmit(() => loginMutation.mutate())}
|
||||||
>
|
>
|
||||||
|
<Title ta={"center"} order={3}>StuVe IT Account - Login</Title>
|
||||||
<Title order={3}>Login mit StuVe IT Account</Title>
|
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
label={"Anmeldename"}
|
label={"Anmeldename"}
|
||||||
|
@ -48,6 +49,22 @@ export default function Login() {
|
||||||
{...formValues.getInputProps("password")}
|
{...formValues.getInputProps("password")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Checkbox
|
||||||
|
required
|
||||||
|
label={
|
||||||
|
<Text>
|
||||||
|
Ich akzeptiere die <Anchor
|
||||||
|
component={Link}
|
||||||
|
target={"_blank"}
|
||||||
|
to={"/privacy-policy"}
|
||||||
|
>
|
||||||
|
Datenschutzerklärung
|
||||||
|
</Anchor>.
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
{...formValues.getInputProps("privacy", {type: "checkbox"})}
|
||||||
|
/>
|
||||||
|
|
||||||
{loginMutation.error && (
|
{loginMutation.error && (
|
||||||
<Alert variant="transparent" color="red" title="Fehler" icon={<IconExclamationCircle/>}>
|
<Alert variant="transparent" color="red" title="Fehler" icon={<IconExclamationCircle/>}>
|
||||||
{loginMutation.error.message}
|
{loginMutation.error.message}
|
||||||
|
@ -56,7 +73,7 @@ export default function Login() {
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
loading={loginMutation.isPending}
|
loading={loginMutation.isPending}
|
||||||
disabled={formValues.values.username === "" || formValues.values.password === ""}
|
disabled={formValues.values.username === "" || formValues.values.password === "" || !formValues.values.privacy}
|
||||||
type={"submit"}
|
type={"submit"}
|
||||||
>
|
>
|
||||||
Einloggen
|
Einloggen
|
|
@ -1,5 +1,5 @@
|
||||||
import {Menu, rem} from "@mantine/core";
|
import {Menu, rem} from "@mantine/core";
|
||||||
import {NAV_ITEMS} from "../../../config.ts";
|
import {NAV_ITEMS} from "../../../../config.ts";
|
||||||
import {NavLink} from "react-router-dom";
|
import {NavLink} from "react-router-dom";
|
||||||
import {Fragment} from "react";
|
import {Fragment} from "react";
|
||||||
|
|
|
@ -1,16 +1,9 @@
|
||||||
import {ActionIcon, Button, Divider, List, Modal, rem, Text, ThemeIcon, Title} from "@mantine/core";
|
import {ActionIcon, Button, Code, Divider, Modal, Text, ThemeIcon, Title} from "@mantine/core";
|
||||||
import {
|
import {IconBalloon, IconCalendar, IconId, IconLogout, IconServer, IconServerOff} from "@tabler/icons-react";
|
||||||
IconBalloon,
|
|
||||||
IconCalendar,
|
|
||||||
IconId,
|
|
||||||
IconLogout,
|
|
||||||
IconServer,
|
|
||||||
IconServerOff,
|
|
||||||
IconUsersGroup
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import {useDisclosure} from "@mantine/hooks";
|
import {useDisclosure} from "@mantine/hooks";
|
||||||
import {usePB} from "../../lib/pocketbase.tsx";
|
import {usePB} from "../../../lib/pocketbase.tsx";
|
||||||
import classes from "./index.module.css";
|
import classes from "./index.module.css";
|
||||||
|
import LdapGroupsDisplay from "../../auth/LdapGroupsDisplay.tsx";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This component renders a user menu button and a user menu modal with user information.
|
* This component renders a user menu button and a user menu modal with user information.
|
||||||
|
@ -34,9 +27,7 @@ export default function UserMenu() {
|
||||||
<IconId/>
|
<IconId/>
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
|
|
||||||
<Text>
|
<Code>{user?.id}</Code>
|
||||||
ID {user?.uidNumber}
|
|
||||||
</Text>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={classes.row}>
|
<div className={classes.row}>
|
||||||
|
@ -50,8 +41,8 @@ export default function UserMenu() {
|
||||||
<Text>
|
<Text>
|
||||||
{user?.accountExpires ? (
|
{user?.accountExpires ? (
|
||||||
|
|
||||||
user?.accountExpires?.getTime() > Date.now() ? (
|
new Date(user?.accountExpires).getTime() > Date.now() ? (
|
||||||
"Account ist aktiv und läuft am " + user?.accountExpires?.toLocaleDateString() + " ab"
|
"Account ist aktiv und läuft am " + new Date(user?.accountExpires).toLocaleDateString() + " ab"
|
||||||
) : (
|
) : (
|
||||||
"Account ist abgelaufen"
|
"Account ist abgelaufen"
|
||||||
)
|
)
|
||||||
|
@ -82,33 +73,17 @@ export default function UserMenu() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Text>
|
<Text>
|
||||||
{apiIsHealthy ? "API ist erreichbar" : "API ist nicht erreichbar"}
|
{apiIsHealthy ? "Das Backend ist erreichbar" : "Das Backend ist nicht erreichbar"}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{user?.memberOf.length && <Divider/>}
|
{user?.memberOf.length && <>
|
||||||
|
<Divider/>
|
||||||
|
<Title order={5}>Deine Gruppen</Title>
|
||||||
|
<LdapGroupsDisplay groups={user?.expand.memberOf}/>
|
||||||
|
<Divider/>
|
||||||
|
</>}
|
||||||
|
|
||||||
{user?.memberOf.length && <Title order={5}>Deine Gruppen</Title>}
|
|
||||||
|
|
||||||
<List
|
|
||||||
spacing="xs"
|
|
||||||
size="sm"
|
|
||||||
center
|
|
||||||
icon={
|
|
||||||
<ThemeIcon color="teal" size={24} radius="xl">
|
|
||||||
<IconUsersGroup style={{width: rem(16), height: rem(16)}}/>
|
|
||||||
</ThemeIcon>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(user?.expand.memberOf || []).map((group) => (
|
|
||||||
<List.Item key={group.id}>
|
|
||||||
{group.cn}
|
|
||||||
</List.Item>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</List>
|
|
||||||
|
|
||||||
{user?.memberOf.length && <Divider/>}
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
leftSection={<IconLogout/>}
|
leftSection={<IconLogout/>}
|
|
@ -1,5 +1,4 @@
|
||||||
.navbar {
|
.navbar {
|
||||||
border-bottom: 1px solid var(--mantine-color-gray-4);
|
|
||||||
height: 56px;
|
height: 56px;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -7,6 +6,8 @@
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
|
||||||
padding: 0 var(--padding);
|
padding: 0 var(--padding);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import {usePB} from "../../lib/pocketbase.tsx";
|
import {usePB} from "../../../lib/pocketbase.tsx";
|
||||||
import classes from "./index.module.css";
|
import classes from "./index.module.css";
|
||||||
import {ActionIcon, Image, Menu, ThemeIcon, useMantineColorScheme} from "@mantine/core";
|
import {ActionIcon, Image, Menu, ThemeIcon, useMantineColorScheme} from "@mantine/core";
|
||||||
import {IconChevronDown, IconMoon, IconSun} from "@tabler/icons-react";
|
import {IconChevronDown, IconMoon, IconSun} from "@tabler/icons-react";
|
|
@ -1,7 +1,12 @@
|
||||||
:root {
|
* {
|
||||||
--gap: var(--mantine-spacing-sm);
|
--gap: var(--mantine-spacing-sm);
|
||||||
--padding: var(--mantine-spacing-md);
|
--padding: var(--mantine-spacing-md);
|
||||||
--border-radius: var(--mantine-radius-md);
|
--border-radius: var(--mantine-radius-md);
|
||||||
|
/*noinspection CssInvalidFunction*/
|
||||||
|
--border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||||
|
--border: 1px solid var(--border-color);
|
||||||
|
--max-content-width: 1280px;
|
||||||
|
--shadow: var(--mantine-shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -34,6 +39,17 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.no-scrollbar {
|
||||||
|
/* Hide scrollbar for WebKit (Safari, Chrome) */
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar for Firefox */
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: inherit; /* Inherits the color from the parent element */
|
color: inherit; /* Inherits the color from the parent element */
|
||||||
text-decoration: none; /* Removes underline */
|
text-decoration: none; /* Removes underline */
|
||||||
|
@ -42,4 +58,68 @@ a {
|
||||||
a:hover, a:active, a:visited {
|
a:hover, a:active, a:visited {
|
||||||
color: inherit; /* Inherits the color from the parent element */
|
color: inherit; /* Inherits the color from the parent element */
|
||||||
text-decoration: none; /* Removes underline */
|
text-decoration: none; /* Removes underline */
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-icon {
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-transparent {
|
||||||
|
padding: 0 var(--padding);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
max-width: var(--max-content-width);
|
||||||
|
|
||||||
|
& > .section-icon:first-of-type {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > h1:first-of-type {
|
||||||
|
margin: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
padding: var(--padding);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
max-width: var(--max-content-width);
|
||||||
|
|
||||||
|
/*noinspection CssInvalidFunction*/
|
||||||
|
background-color: var(--mantine-color-body);
|
||||||
|
|
||||||
|
& > .section-icon:first-of-type {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > h1:first-of-type {
|
||||||
|
margin: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--gap);
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.monospace {
|
||||||
|
font-family: var(--mantine-font-family-monospace);
|
||||||
}
|
}
|
|
@ -0,0 +1,76 @@
|
||||||
|
import dayjs, {Dayjs} from "dayjs";
|
||||||
|
|
||||||
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
|
import duration from "dayjs/plugin/duration";
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
dayjs.extend(duration);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if two dates are the same. This function only checks the date, not the time.
|
||||||
|
* @param d1 date string
|
||||||
|
* @param d2 date string
|
||||||
|
* @return {boolean} True if the dates are the same, false otherwise.
|
||||||
|
*/
|
||||||
|
export const areDatesSame = (d1: string | Date, d2: string | Date) => {
|
||||||
|
const date1 = d1 instanceof Date ? d1 : new Date(d1);
|
||||||
|
const date2 = d2 instanceof Date ? d2 : new Date(d2);
|
||||||
|
return (
|
||||||
|
date1.getFullYear() === date2.getFullYear() &&
|
||||||
|
date1.getMonth() === date2.getMonth() &&
|
||||||
|
date1.getDate() === date2.getDate()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Pretty print a date. The date is formatted as "DD.MM.YYYY".
|
||||||
|
* @param date - The date string to pretty print.
|
||||||
|
* @return {string} The pretty printed date.
|
||||||
|
*/
|
||||||
|
export const pprintDate = (date: string | Date): string => {
|
||||||
|
const d = new Date(date);
|
||||||
|
return `${d.toLocaleDateString(navigator.language, {weekday: 'short'})} ${d.getDate()}.${d.getMonth() + 1}.${d.getFullYear()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function getLocalizedNowWord(locale: string) {
|
||||||
|
const translations = {
|
||||||
|
'en-US': 'now',
|
||||||
|
'de-DE': 'jetzt',
|
||||||
|
'es-ES': 'ahora',
|
||||||
|
'fr-FR': 'maintenant',
|
||||||
|
'it-IT': 'ora',
|
||||||
|
'ja-JP': '今',
|
||||||
|
'zh-CN': '现在',
|
||||||
|
'ru-RU': 'сейчас',
|
||||||
|
'ar-SA': 'الآن'
|
||||||
|
}
|
||||||
|
return translations[locale as keyof typeof translations] || 'now';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const humanDeltaFromNow = (start: string | Date | Dayjs, end: string | Date | Dayjs): {
|
||||||
|
message: string,
|
||||||
|
delta: "PAST" | "FUTURE" | "NOW"
|
||||||
|
} => {
|
||||||
|
|
||||||
|
// check if end is in the past
|
||||||
|
if (dayjs(end).isBefore(dayjs())) {
|
||||||
|
return {
|
||||||
|
message: dayjs(end).fromNow(),
|
||||||
|
delta: "PAST"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if start is in the future
|
||||||
|
if (dayjs(start).isAfter(dayjs())) {
|
||||||
|
return {
|
||||||
|
message: dayjs(start).fromNow(),
|
||||||
|
delta: "FUTURE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// else the event is happening now, return localized "now"
|
||||||
|
return {
|
||||||
|
message: getLocalizedNowWord(navigator.language),
|
||||||
|
delta: "NOW"
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,7 +5,7 @@ import {useInterval} from "@mantine/hooks";
|
||||||
import {useQuery} from "@tanstack/react-query";
|
import {useQuery} from "@tanstack/react-query";
|
||||||
import {TypedPocketBase} from "../models";
|
import {TypedPocketBase} from "../models";
|
||||||
import {PB_BASE_URL, PB_STORAGE_KEY, PB_USER_COLLECTION} from "../../config.ts";
|
import {PB_BASE_URL, PB_STORAGE_KEY, PB_USER_COLLECTION} from "../../config.ts";
|
||||||
import {LdapUser} from "../models/AuthTypes.ts";
|
import {LdapUserModel} from "../models/AuthTypes.ts";
|
||||||
|
|
||||||
const oneMinuteInMs = ms("1 minute");
|
const oneMinuteInMs = ms("1 minute");
|
||||||
|
|
||||||
|
@ -76,7 +76,7 @@ const PocketData = () => {
|
||||||
return {
|
return {
|
||||||
ldapLogin,
|
ldapLogin,
|
||||||
logout,
|
logout,
|
||||||
user: user as LdapUser | null,
|
user: user as LdapUserModel | null,
|
||||||
pb,
|
pb,
|
||||||
refreshUser,
|
refreshUser,
|
||||||
useSubscription,
|
useSubscription,
|
||||||
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
import {usePB} from "./pocketbase.tsx";
|
||||||
|
import {useQuery} from "@tanstack/react-query";
|
||||||
|
import {TypedPocketBase} from "../models";
|
||||||
|
|
||||||
|
type Setting = {
|
||||||
|
value: string;
|
||||||
|
description?: string;
|
||||||
|
updated: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
type Settings = {
|
||||||
|
privacyPolicy: Setting
|
||||||
|
agb: Setting
|
||||||
|
stexGroup: Setting
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSettings = async (pb: TypedPocketBase) => {
|
||||||
|
const data = await pb.collection('settings').getFullList()
|
||||||
|
return data.reduce((acc, s) => {
|
||||||
|
acc[s.key as keyof Settings] = {
|
||||||
|
value: s.value,
|
||||||
|
description: s.description,
|
||||||
|
updated: new Date(s.updated)
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, {} as Settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const useSettings = () => {
|
||||||
|
const {pb} = usePB()
|
||||||
|
|
||||||
|
const settingsQuery = useQuery({
|
||||||
|
queryKey: ['settings'],
|
||||||
|
queryFn: async () => await loadSettings(pb)
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
return settingsQuery.data
|
||||||
|
}
|
|
@ -0,0 +1,43 @@
|
||||||
|
import {PB_BASE_URL} from "../../config.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function creates a query string from an object.
|
||||||
|
* If a value is undefined or an empty string, it will not be included in the query string.
|
||||||
|
* @param {Object} params - The object to create the query string from.
|
||||||
|
* @return {string} The query string.
|
||||||
|
*/
|
||||||
|
export const createQueryParams = (params: { [key: string]: string | number | boolean | undefined }): string =>
|
||||||
|
Object.keys(params)
|
||||||
|
.filter(key => params[key] !== undefined && params[key] !== "")
|
||||||
|
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key] as string | number | boolean)}`)
|
||||||
|
.join('&')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The level of error correction to use for the QR code.
|
||||||
|
*/
|
||||||
|
export type ErrorCorrectionLevel = "L" | "M" | "Q" | "H"
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a URL for a QR code.
|
||||||
|
* @param options
|
||||||
|
* @param options.data - The data to encode in the QR code.
|
||||||
|
* @param options.ecc - The level of error correction to use for the QR code.
|
||||||
|
* @param options.scale - The scale of the QR code.
|
||||||
|
* @param options.border - The border of the QR code.
|
||||||
|
* @param options.color - The color of the QR code.
|
||||||
|
* @param options.colorBackground - The background color of the QR code.
|
||||||
|
* @return {string} The URL for the QR code.
|
||||||
|
*/
|
||||||
|
export const createQRCodeUrl = (options: {
|
||||||
|
data: string,
|
||||||
|
ecc: ErrorCorrectionLevel,
|
||||||
|
scale: number,
|
||||||
|
border: number,
|
||||||
|
color: string,
|
||||||
|
colorBackground: string
|
||||||
|
}): string => {
|
||||||
|
return `${PB_BASE_URL}/api/qr/v1?${createQueryParams(options)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,9 @@ import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import Router from './Router.tsx'
|
import Router from './Router.tsx'
|
||||||
import '@mantine/core/styles.css';
|
import '@mantine/core/styles.css';
|
||||||
|
import '@mantine/code-highlight/styles.css';
|
||||||
|
import '@mantine/dates/styles.css';
|
||||||
|
import '@mantine/tiptap/styles.css';
|
||||||
import {createTheme, DEFAULT_THEME, MantineProvider, mergeMantineTheme} from "@mantine/core";
|
import {createTheme, DEFAULT_THEME, MantineProvider, mergeMantineTheme} from "@mantine/core";
|
||||||
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
|
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
|
||||||
import {PocketBaseProvider} from "./lib/pocketbase.tsx";
|
import {PocketBaseProvider} from "./lib/pocketbase.tsx";
|
||||||
|
@ -24,7 +27,7 @@ export const theme = mergeMantineTheme(DEFAULT_THEME, themeOverride);
|
||||||
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<MantineProvider theme={theme} >
|
<MantineProvider theme={theme}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<PocketBaseProvider>
|
<PocketBaseProvider>
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|
|
@ -1,35 +1,33 @@
|
||||||
import {RecordModel} from "pocketbase";
|
import {RecordModel} from "pocketbase";
|
||||||
|
|
||||||
export type LdapUser = {
|
export type LdapUserModel = {
|
||||||
|
|
||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string;
|
||||||
cn: string;
|
cn: string;
|
||||||
dn: string;
|
dn: string;
|
||||||
uidNumber: string;
|
|
||||||
sn: string;
|
sn: string;
|
||||||
givenName: string;
|
givenName: string;
|
||||||
accountExpires: Date | null;
|
accountExpires: string | null;
|
||||||
memberOf: string[];
|
memberOf: string[];
|
||||||
|
|
||||||
expand: {
|
expand: {
|
||||||
memberOf: LdapGroup[]
|
memberOf: LdapGroupModel[]
|
||||||
}
|
}
|
||||||
} & RecordModel
|
} & RecordModel
|
||||||
|
|
||||||
export type LdapGroup = {
|
export type LdapGroupModel = {
|
||||||
gidNumber: string;
|
|
||||||
description: string;
|
description: string;
|
||||||
cn: string;
|
cn: string;
|
||||||
dn: string;
|
dn: string;
|
||||||
memberOf: string[];
|
memberOf: string[];
|
||||||
|
|
||||||
expand: {
|
expand: {
|
||||||
memberOf: LdapGroup[]
|
memberOf: LdapGroupModel[]
|
||||||
}
|
}
|
||||||
} & RecordModel
|
} & RecordModel
|
||||||
|
|
||||||
export type LdapSyncLog = {
|
export type LdapSyncLogModel = {
|
||||||
usersFound: number;
|
usersFound: number;
|
||||||
usersSynced: number;
|
usersSynced: number;
|
||||||
usersRemoved: number;
|
usersRemoved: number;
|
||||||
|
|
|
@ -0,0 +1,61 @@
|
||||||
|
import {LdapUserModel} from "./AuthTypes.ts";
|
||||||
|
import {RecordModel} from "pocketbase";
|
||||||
|
|
||||||
|
export type EventModel = {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
adminMembers: string[];
|
||||||
|
img?: string; // png, jpg, gif
|
||||||
|
location: string;
|
||||||
|
isStuveEvent: boolean;
|
||||||
|
additionalAgb?: string;
|
||||||
|
hideFromPublic: boolean;
|
||||||
|
expand: {
|
||||||
|
adminMembers: LdapUserModel[];
|
||||||
|
}
|
||||||
|
} & RecordModel
|
||||||
|
|
||||||
|
export type EventListModel = {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
registrable: boolean;
|
||||||
|
event: string
|
||||||
|
questionSchema: object
|
||||||
|
expand: {
|
||||||
|
event: EventModel;
|
||||||
|
}
|
||||||
|
} & RecordModel
|
||||||
|
|
||||||
|
export type EventListSlotModel = {
|
||||||
|
name: string;
|
||||||
|
eventList: string;
|
||||||
|
description?: string;
|
||||||
|
slotStart: string;
|
||||||
|
slotEnd: string;
|
||||||
|
expand: {
|
||||||
|
eventList: EventListModel;
|
||||||
|
}
|
||||||
|
} & RecordModel
|
||||||
|
|
||||||
|
export type EventListSlotEntry = {
|
||||||
|
questionAnswers: object;
|
||||||
|
acceptTerms: boolean;
|
||||||
|
eventListSlot: string;
|
||||||
|
expand: {
|
||||||
|
eventListSlot: EventListSlotModel;
|
||||||
|
}
|
||||||
|
} & ({
|
||||||
|
user: string;
|
||||||
|
expand: {
|
||||||
|
user: LdapUserModel;
|
||||||
|
}
|
||||||
|
} | {
|
||||||
|
email: string;
|
||||||
|
token: string;
|
||||||
|
}) & RecordModel
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,24 @@
|
||||||
import PocketBase, {RecordService} from "pocketbase";
|
import PocketBase, {RecordModel, RecordService} from "pocketbase";
|
||||||
import {LdapGroup, LdapSyncLog, LdapUser} from "./AuthTypes.ts";
|
import {LdapGroupModel, LdapSyncLogModel, LdapUserModel} from "./AuthTypes.ts";
|
||||||
|
import {EventModel} from "./EventTypes.ts";
|
||||||
|
|
||||||
|
export type SettingsModel = {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
description?: string;
|
||||||
|
public: boolean;
|
||||||
|
} & RecordModel
|
||||||
|
|
||||||
export interface TypedPocketBase extends PocketBase {
|
export interface TypedPocketBase extends PocketBase {
|
||||||
collection(idOrName: string): RecordService // default fallback for any other collection
|
collection(idOrName: string): RecordService // default fallback for any other collection
|
||||||
|
collection(idOrName: 'settings'): RecordService<SettingsModel>
|
||||||
|
|
||||||
collection(idOrName: 'ldap_users'): RecordService<LdapUser>
|
collection(idOrName: 'ldap_users'): RecordService<LdapUserModel>
|
||||||
|
|
||||||
collection(idOrName: 'ldap_groups'): RecordService<LdapGroup>
|
collection(idOrName: 'ldap_groups'): RecordService<LdapGroupModel>
|
||||||
|
|
||||||
|
collection(idOrName: 'ldap_sync_logs'): RecordService<LdapSyncLogModel>
|
||||||
|
|
||||||
|
collection(idOrName: 'events'): RecordService<EventModel>
|
||||||
|
|
||||||
collection(idOrName: 'ldap_sync_logs'): RecordService<LdapSyncLog>
|
|
||||||
}
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
.grid {
|
||||||
|
max-width: var(--max-content-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.data {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
padding: calc(var(--gap) / 2) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > :not(:last-child) {
|
||||||
|
border-bottom: var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: calc(var(--gap) / 2);
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,299 @@
|
||||||
|
import {Link} from "react-router-dom";
|
||||||
|
import {usePB} from "../../../lib/pocketbase.tsx";
|
||||||
|
import {QueryObserverResult, useMutation} from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Anchor,
|
||||||
|
Breadcrumbs,
|
||||||
|
Button,
|
||||||
|
Grid,
|
||||||
|
Group,
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
Transition
|
||||||
|
} from "@mantine/core";
|
||||||
|
import PBAvatar from "../../../components/PBAvatar.tsx";
|
||||||
|
import UsersDisplay from "../../../components/auth/UsersDisplay.tsx";
|
||||||
|
|
||||||
|
import classes from "./index.module.css";
|
||||||
|
import {
|
||||||
|
IconAdjustments,
|
||||||
|
IconAlertTriangle,
|
||||||
|
IconArchive,
|
||||||
|
IconCalendar,
|
||||||
|
IconCheck,
|
||||||
|
IconHourglass,
|
||||||
|
IconLock,
|
||||||
|
IconMap,
|
||||||
|
IconPencil,
|
||||||
|
IconSectionSign,
|
||||||
|
IconSettings,
|
||||||
|
IconSettingsOff,
|
||||||
|
IconSparkles,
|
||||||
|
IconTrash
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import {areDatesSame, humanDeltaFromNow, pprintDate} from "../../../lib/datetime.ts";
|
||||||
|
import {useDisclosure} from "@mantine/hooks";
|
||||||
|
import InnerHtml from "../../../components/InnerHtml";
|
||||||
|
import EditEventModal from "../EditEventModal.tsx";
|
||||||
|
import {EventModel} from "../../../models/EventTypes.ts";
|
||||||
|
|
||||||
|
|
||||||
|
const ArchiveEventModal = (props: {
|
||||||
|
event: EventModel,
|
||||||
|
opened: boolean,
|
||||||
|
onClose: () => void
|
||||||
|
}) => {
|
||||||
|
const {pb} = usePB()
|
||||||
|
|
||||||
|
const archiveMutation = useMutation({
|
||||||
|
mutationFn: async () => await pb.collection("events").update(props.event.id, {
|
||||||
|
"adminMembers": []
|
||||||
|
}),
|
||||||
|
onSuccess: props.onClose
|
||||||
|
})
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Modal opened={props.opened} onClose={props.onClose} withCloseButton={false} size={"sm"}>
|
||||||
|
<Stack align="center" gap={"xl"}>
|
||||||
|
<Title order={2} c={"orange"}>
|
||||||
|
Achtung
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
<Text c={"dimmed"}>
|
||||||
|
Wenn Du dieses Ereignis archivierst, kann es weiterhin gesehen, jedoch nicht mehr bearbeitet
|
||||||
|
werden.
|
||||||
|
<br/>
|
||||||
|
Nur ein Systemadministrator (C-Ref) kann diese Aktion rückgängig machen.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{
|
||||||
|
archiveMutation.error &&
|
||||||
|
<Alert title={"Fehler"} color={"red"}>
|
||||||
|
Das Event konnte nicht archiviert werden:
|
||||||
|
{" "}
|
||||||
|
{archiveMutation.error.message}
|
||||||
|
</Alert>
|
||||||
|
}
|
||||||
|
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
color={"orange"}
|
||||||
|
leftSection={<IconAlertTriangle/>}
|
||||||
|
onClick={() => archiveMutation.mutate()}
|
||||||
|
loading={archiveMutation.isPending}
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color={"green"}
|
||||||
|
leftSection={<IconCheck/>}
|
||||||
|
onClick={props.onClose}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EventView({event, refetchEvent}: {
|
||||||
|
event: EventModel,
|
||||||
|
refetchEvent: () => Promise<QueryObserverResult<EventModel, Error>>
|
||||||
|
}) {
|
||||||
|
|
||||||
|
const {user} = usePB()
|
||||||
|
|
||||||
|
const [showSettings, showSettingsHandler] = useDisclosure(false)
|
||||||
|
const [showEditModal, showEditModalHandler] = useDisclosure(false)
|
||||||
|
const [showArchiveModal, showArchiveModalHandler] = useDisclosure(false)
|
||||||
|
|
||||||
|
// the time delta of the event from now
|
||||||
|
const delta = humanDeltaFromNow(event.startDate, event.endDate)
|
||||||
|
|
||||||
|
// whether the user is an admin of the event
|
||||||
|
const isEventAdmin = user && event.adminMembers.includes(user.id)
|
||||||
|
|
||||||
|
const readOnlyEvent = event.adminMembers.length == 0
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<div className={"section-transparent"}>
|
||||||
|
<Breadcrumbs>{[
|
||||||
|
<Anchor component={Link} to={"/"}>
|
||||||
|
Home
|
||||||
|
</Anchor>,
|
||||||
|
<Anchor component={Link} to={"/events"}>
|
||||||
|
Events
|
||||||
|
</Anchor>,
|
||||||
|
<Anchor component={Link} to={`/events/${event.id}`}>
|
||||||
|
{event.name}
|
||||||
|
</Anchor>
|
||||||
|
]}</Breadcrumbs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
readOnlyEvent && <Alert
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
title="Archives Event"
|
||||||
|
icon={<IconArchive/>}
|
||||||
|
className={"section-transparent"}
|
||||||
|
p={"sm"}
|
||||||
|
>
|
||||||
|
Dieses Event ist archiviert und wird nicht mehr verwaltet.
|
||||||
|
</Alert>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div className={"section"}>
|
||||||
|
<PBAvatar className={"section-icon"} model={event} name={event.name}
|
||||||
|
img={event.img}/>
|
||||||
|
<Title order={1} c={isEventAdmin ? "green" : ""}>
|
||||||
|
{isEventAdmin &&
|
||||||
|
<ThemeIcon me={"sm"} variant="light" radius={"xl"} size="xl"
|
||||||
|
color="green"><IconPencil/></ThemeIcon>}
|
||||||
|
{event.name}
|
||||||
|
</Title>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEventAdmin &&
|
||||||
|
<>
|
||||||
|
<EditEventModal event={event} opened={showEditModal} onClose={() => {
|
||||||
|
refetchEvent().then(() => {
|
||||||
|
showEditModalHandler.close()
|
||||||
|
})
|
||||||
|
}}/>
|
||||||
|
|
||||||
|
<ArchiveEventModal event={event} opened={showArchiveModal} onClose={() => {
|
||||||
|
refetchEvent().then(() =>
|
||||||
|
showArchiveModalHandler.close()
|
||||||
|
)
|
||||||
|
}}/>
|
||||||
|
|
||||||
|
<div className={`section-transparent group`} style={{gap: 0}}>
|
||||||
|
<Button
|
||||||
|
leftSection={showSettings ? <IconSettingsOff/> : <IconSettings/>}
|
||||||
|
variant={"transparent"}
|
||||||
|
onClick={showSettingsHandler.toggle}
|
||||||
|
>
|
||||||
|
Einstellungen
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Transition
|
||||||
|
mounted={showSettings}
|
||||||
|
transition="fade-right"
|
||||||
|
duration={100}
|
||||||
|
timingFunction="ease-out"
|
||||||
|
>
|
||||||
|
{(styles) => <div style={styles}>
|
||||||
|
<Button
|
||||||
|
leftSection={<IconPencil/>}
|
||||||
|
variant={"transparent"}
|
||||||
|
color={"green"}
|
||||||
|
onClick={showEditModalHandler.open}
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
leftSection={<IconLock/>}
|
||||||
|
variant={"transparent"}
|
||||||
|
color={"orange"}
|
||||||
|
onClick={showArchiveModalHandler.open}
|
||||||
|
>
|
||||||
|
Archivieren
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
leftSection={<IconTrash/>}
|
||||||
|
variant={"transparent"}
|
||||||
|
color={"red"}
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
</div>}
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
<Grid className={classes.grid} gutter={"sm"}>
|
||||||
|
<Grid.Col span={{base: 12, sm: 3}}>
|
||||||
|
<div className={`section`}>
|
||||||
|
<h2>Daten</h2>
|
||||||
|
<div className={classes.data}>
|
||||||
|
{
|
||||||
|
event.isStuveEvent && <>
|
||||||
|
<div className={"group"}>
|
||||||
|
<IconSparkles size={"1rem"}/>
|
||||||
|
StuVe Event
|
||||||
|
</div>
|
||||||
|
<Link to={"/terms-and-conditions"} className={"group"}>
|
||||||
|
<IconSectionSign size={"1rem"}/>
|
||||||
|
<Text td={"underline"}>
|
||||||
|
AGB der StuVe
|
||||||
|
</Text>
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
event.additionalAgb && (
|
||||||
|
<Link to={`/events/${event.id}/terms-and-conditions`} className={"group"}>
|
||||||
|
<IconSectionSign size={"1rem"}/>
|
||||||
|
<Text td={"underline"}>
|
||||||
|
Event AGB
|
||||||
|
</Text>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
<div className={"group"}>
|
||||||
|
<IconMap size={"1rem"}/>
|
||||||
|
{event.location}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={"group"}>
|
||||||
|
<IconHourglass size={"1rem"}/>
|
||||||
|
{delta.message}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={"group"}>
|
||||||
|
<IconCalendar size={"1rem"}/>
|
||||||
|
{areDatesSame(event.startDate, event.endDate) ?
|
||||||
|
pprintDate(event.startDate)
|
||||||
|
:
|
||||||
|
`${pprintDate(event.startDate)} - ${pprintDate(event.endDate)}`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
event.expand?.adminMembers &&
|
||||||
|
<div className={classes.stack}>
|
||||||
|
<div className={"group"}>
|
||||||
|
<IconAdjustments size={"1rem"}/>
|
||||||
|
Verwaltet von:
|
||||||
|
</div>
|
||||||
|
<UsersDisplay users={event.expand.adminMembers}/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Grid.Col>
|
||||||
|
{
|
||||||
|
event.description &&
|
||||||
|
<Grid.Col span={{base: 12, sm: 9}}>
|
||||||
|
<div className={`section`} style={{minWidth: 0}}>
|
||||||
|
<h2>Beschreibung</h2>
|
||||||
|
<InnerHtml html={event.description ?? ""}/>
|
||||||
|
</div>
|
||||||
|
</Grid.Col>
|
||||||
|
}
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
export default function EventListView() {
|
||||||
|
return <>
|
||||||
|
<div className={"section"}>
|
||||||
|
<h1>List</h1>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
export default function EventTermsAndConditions() {
|
||||||
|
return <>
|
||||||
|
<div className={"section"}>
|
||||||
|
<h1>Event AGB</h1>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
|
@ -0,0 +1,225 @@
|
||||||
|
import {Alert, Avatar, Button, Checkbox, Divider, Modal, Stack, Textarea, TextInput} from "@mantine/core";
|
||||||
|
import {EventModel} from "../../models/EventTypes.ts";
|
||||||
|
import {hasLength, useForm} from "@mantine/form";
|
||||||
|
import {usePB} from "../../lib/pocketbase.tsx";
|
||||||
|
import {DateTimePicker} from "@mantine/dates";
|
||||||
|
import TextEditor from "../../components/input/Editor";
|
||||||
|
import UserInput from "../../components/auth/UserInput.tsx";
|
||||||
|
import {LdapUserModel} from "../../models/AuthTypes.ts";
|
||||||
|
import {IconCheck, IconInfoCircle, IconX} from "@tabler/icons-react";
|
||||||
|
import ImageSelect from "../../components/input/ImageSelect.tsx";
|
||||||
|
import {useMutation} from "@tanstack/react-query";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import {useNavigate} from "react-router-dom";
|
||||||
|
|
||||||
|
export default function EditEventModal({event, onClose, opened}: {
|
||||||
|
event?: EventModel,
|
||||||
|
opened: boolean,
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const {user, pb} = usePB()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const formValues = useForm({
|
||||||
|
initialValues: {
|
||||||
|
name: event?.name ?? "",
|
||||||
|
startDate: event?.startDate ? new Date(event.startDate) : null,
|
||||||
|
endDate: event?.endDate ? new Date(event.endDate) : null,
|
||||||
|
location: event?.location ?? "",
|
||||||
|
description: event?.description ?? "",
|
||||||
|
adminMembers: event?.expand.adminMembers ?? [user] as LdapUserModel[],
|
||||||
|
img: null as File | null,
|
||||||
|
isStuveEvent: event?.isStuveEvent ?? true,
|
||||||
|
additionalAgb: event?.externalAgb ?? "",
|
||||||
|
hideFromPublic: event?.hideFromPublic ?? false
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
name: hasLength({min: 4, max: 50}, 'Der Name muss zwischen 4 und 50 Zeichen lang sein.'),
|
||||||
|
startDate: (value) => dayjs(value).isAfter(dayjs(), "day") ? null : "Das Startdatum muss in der Zukunft liegen.",
|
||||||
|
endDate: (value, values) => dayjs(value).isAfter(dayjs(values.startDate), "day") ? null : "Das Enddatum muss nach dem Startdatum liegen.",
|
||||||
|
location: hasLength({min: 4, max: 500}, 'Der Ort muss zwischen 4 und 500 Zeichen lang sein.'),
|
||||||
|
adminMembers: (value) => value.length > 0 ? null : "Es muss mindestens ein Admin ausgewählt werden.",
|
||||||
|
additionalAgb: (value, values) => !values.isStuveEvent && value.length < 10 ? "Die AGB für externe Events müssen angegeben werden. (min. 10 Zeichen)" : null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const upsertEventMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formValues.values.img && formData.append("img", formValues.values.img as File)
|
||||||
|
formData.append("name", formValues.values.name)
|
||||||
|
formData.append("startDate", formValues.values.startDate!.toISOString())
|
||||||
|
formData.append("endDate", formValues.values.endDate!.toISOString())
|
||||||
|
formData.append("location", formValues.values.location)
|
||||||
|
formData.append("description", formValues.values.description)
|
||||||
|
formData.append("isStuveEvent", formValues.values.isStuveEvent.toString())
|
||||||
|
formData.append("additionalAgb", formValues.values.additionalAgb)
|
||||||
|
formData.append("hideFromPublic", (
|
||||||
|
// all stuve are public by default
|
||||||
|
formValues.values.isStuveEvent ? false : formValues.values.hideFromPublic
|
||||||
|
).toString())
|
||||||
|
formValues.values.adminMembers.forEach((member) => {
|
||||||
|
formData.append("adminMembers", member.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (event) {
|
||||||
|
return await pb.collection("events").update(event.id, formData)
|
||||||
|
} else {
|
||||||
|
return await pb.collection("events").create(formData)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: (res) => {
|
||||||
|
onClose()
|
||||||
|
navigate(`/events/${res.id}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Modal
|
||||||
|
size={"xl"}
|
||||||
|
title={event ? `Event '${event.name}' bearbeiten` : "Neues Event erstellen"}
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
<form className={"stack"} onSubmit={formValues.onSubmit(() => upsertEventMutation.mutate())}>
|
||||||
|
<Stack align={"center"} justify={"center"} h={200}>
|
||||||
|
{
|
||||||
|
!formValues.values.img &&
|
||||||
|
<ImageSelect
|
||||||
|
onChange={(files) => formValues.setFieldValue("img", files[0])}
|
||||||
|
fileCount={formValues.values.img ? 1 : 0}
|
||||||
|
maxFileCount={1}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
formValues.values.img && <>
|
||||||
|
<Avatar
|
||||||
|
src={URL.createObjectURL(formValues.values.img)}
|
||||||
|
alt={"Event Image"}
|
||||||
|
color={"blue"}
|
||||||
|
radius={20000}
|
||||||
|
size="xl"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant={"transparent"}
|
||||||
|
onClick={() => {
|
||||||
|
formValues.setFieldValue("img", null)
|
||||||
|
}}
|
||||||
|
color={"red"}
|
||||||
|
leftSection={<IconX/>}
|
||||||
|
>
|
||||||
|
{event ? "Altes Bild verwenden" : "Bild entfernen"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label={"Name"}
|
||||||
|
placeholder={"Name des Events"}
|
||||||
|
required
|
||||||
|
{...formValues.getInputProps("name")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label={"Ort"}
|
||||||
|
placeholder={"z.B. 'Forum Uni Süd', 'Online', ..."}
|
||||||
|
required
|
||||||
|
{...formValues.getInputProps("location")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DateTimePicker
|
||||||
|
label={"Startdatum"}
|
||||||
|
required
|
||||||
|
{...formValues.getInputProps("startDate")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DateTimePicker
|
||||||
|
label={"Enddatum"}
|
||||||
|
required
|
||||||
|
{...formValues.getInputProps("endDate")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UserInput
|
||||||
|
required
|
||||||
|
label={"Event-Admins"}
|
||||||
|
description={"Die Event-Admins können das Event bearbeiten."}
|
||||||
|
error={formValues.errors.adminMembers}
|
||||||
|
selectedRecords={formValues.values.adminMembers}
|
||||||
|
setSelectedRecords={(records) => formValues.setFieldValue("adminMembers", records)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Alert variant="light" color="blue" title="StuVe Events" icon={<IconInfoCircle/>}>
|
||||||
|
Bei StuVe Events (z.B. Uni-Party, Fachschafts-Event, ...) gelten AGB der StuVe
|
||||||
|
und werden zusammen mit der Studierenden Exekutive verwaltet.
|
||||||
|
Dies ist aus rechtlichen Gründen notwendig.
|
||||||
|
<br/>
|
||||||
|
Externe Events (z.B. private Feiern, ...), haben eigene AGB
|
||||||
|
und die StuVe ist nicht der Veranstalter.
|
||||||
|
|
||||||
|
<Divider mt={"sm"} mb={"sm"}/>
|
||||||
|
|
||||||
|
<Checkbox
|
||||||
|
label={"StuVe Event"}
|
||||||
|
{...formValues.getInputProps("isStuveEvent", {type: "checkbox"})}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{
|
||||||
|
!formValues.values.isStuveEvent && <>
|
||||||
|
<Checkbox
|
||||||
|
mt={"sm"}
|
||||||
|
label={"Im Kalender und in der Event-Liste verbergen. Alle mit Link können das Event trotzdem sehen."}
|
||||||
|
{...formValues.getInputProps("hideFromPublic", {type: "checkbox"})}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
label={"Zusätzliche Event-AGB"}
|
||||||
|
placeholder={"Für StuVe Events gelten immer auch die StuVe AGB. Für externe Events müssen eigene AGB angegeben werden."}
|
||||||
|
required
|
||||||
|
{...formValues.getInputProps("additionalAgb")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider/>
|
||||||
|
|
||||||
|
<TextEditor
|
||||||
|
placeholder={"Erzähle etwas über das Event..."}
|
||||||
|
noBorder
|
||||||
|
value={formValues.values.description}
|
||||||
|
onChange={(value) => formValues.setFieldValue("description", value)}
|
||||||
|
error={formValues.errors.description}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{
|
||||||
|
upsertEventMutation.error &&
|
||||||
|
<Alert title={"Fehler"} color={"red"}>
|
||||||
|
{event ? "Das Event konnte nicht bearbeitet werden: " : "Das Event konnte nicht erstellt werden: "}
|
||||||
|
{upsertEventMutation.error.message}
|
||||||
|
</Alert>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div className={"group"}>
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
color={"orange"}
|
||||||
|
leftSection={<IconX/>}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color={"green"}
|
||||||
|
leftSection={<IconCheck/>}
|
||||||
|
type={"submit"}
|
||||||
|
loading={upsertEventMutation.isPending}
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
}
|
|
@ -0,0 +1,81 @@
|
||||||
|
import {usePB} from "../../../lib/pocketbase.tsx";
|
||||||
|
import {useNavigate} from "react-router-dom";
|
||||||
|
import {useState} from "react";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import {useQuery} from "@tanstack/react-query";
|
||||||
|
import {LoadingOverlay} from "@mantine/core";
|
||||||
|
import {Calendar, dayjsLocalizer} from "react-big-calendar";
|
||||||
|
import "./eventCalender.scss"
|
||||||
|
|
||||||
|
const localizer = dayjsLocalizer(dayjs)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This component displays a calendar with events. It uses the useQuery hook to fetch events from the database.
|
||||||
|
* The events are displayed in a react-big-calender component. The user can click on an event to navigate to the event's detail page.
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
export const EventCalendar = () => {
|
||||||
|
|
||||||
|
const {pb} = usePB()
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [selectedMonth, setSelectedMonth] = useState({
|
||||||
|
start: dayjs().startOf("month"),
|
||||||
|
end: dayjs().endOf("month")
|
||||||
|
})
|
||||||
|
|
||||||
|
const eventQuery = useQuery({
|
||||||
|
queryKey: ["events", `${selectedMonth.start} - ${selectedMonth.end}`],
|
||||||
|
queryFn: async () => {
|
||||||
|
|
||||||
|
const startOfMonth = selectedMonth.start.toISOString()
|
||||||
|
const endOfMonth = selectedMonth.end.toISOString()
|
||||||
|
|
||||||
|
const data = await pb.collection("events").getFullList({
|
||||||
|
filter:
|
||||||
|
`( ` +
|
||||||
|
`(startDate >= "${startOfMonth}" && startDate <= "${endOfMonth}")` +
|
||||||
|
` || ` +
|
||||||
|
`(endDate >= "${startOfMonth}" && endDate <= "${endOfMonth}")` +
|
||||||
|
` ) ` +
|
||||||
|
` && ` +
|
||||||
|
`hideFromPublic = false`,
|
||||||
|
})
|
||||||
|
|
||||||
|
return data.map(event => ({
|
||||||
|
title: event.name,
|
||||||
|
start: new Date(event.startDate),
|
||||||
|
end: new Date(event.endDate),
|
||||||
|
allDay: false,
|
||||||
|
id: event.id
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<div style={{position: "relative"}}>
|
||||||
|
<LoadingOverlay visible={eventQuery.isFetching}/>
|
||||||
|
<Calendar
|
||||||
|
popup={true}
|
||||||
|
|
||||||
|
onNavigate={(date) => setSelectedMonth({
|
||||||
|
start: dayjs(date).startOf("month"),
|
||||||
|
end: dayjs(date).endOf("month")
|
||||||
|
})}
|
||||||
|
|
||||||
|
onSelectEvent={(event) => {
|
||||||
|
navigate(`/events/${event.id}`)
|
||||||
|
}}
|
||||||
|
|
||||||
|
localizer={localizer}
|
||||||
|
events={eventQuery.data || []}
|
||||||
|
|
||||||
|
startAccessor="start"
|
||||||
|
endAccessor="end"
|
||||||
|
|
||||||
|
style={{height: 500}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
|
@ -0,0 +1,76 @@
|
||||||
|
$color-primary: #228be6;
|
||||||
|
|
||||||
|
$color: #f8f9fa;
|
||||||
|
|
||||||
|
$event-bg: $color-primary;
|
||||||
|
|
||||||
|
$event-border: darken($color-primary, 10%);
|
||||||
|
$event-outline: $color-primary;
|
||||||
|
|
||||||
|
$calendar-border: rgba(173, 181, 189, 0.5);
|
||||||
|
$cell-border: rgba(173, 181, 189, 0.5);
|
||||||
|
|
||||||
|
$out-of-range-color: var(--mantine-color-gray-6);
|
||||||
|
$out-of-range-bg-color: rgba(173, 181, 189, 0.1);
|
||||||
|
|
||||||
|
$time-selection-color: $color;
|
||||||
|
$time-selection-bg-color: rgba(0, 0, 0, 0.5);
|
||||||
|
$date-selection-bg-color: rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
$event-color: $color;
|
||||||
|
$event-border-radius: 0.5rem;
|
||||||
|
$event-padding: 2px 6px;
|
||||||
|
|
||||||
|
$btn-color: $color-primary;
|
||||||
|
$btn-bg: rgba(0, 0, 0, 0);
|
||||||
|
$btn-border: rgba(0, 0, 0, 0);
|
||||||
|
|
||||||
|
$current-time-color: #40c057;
|
||||||
|
|
||||||
|
$today-highlight-bg: rgba(34, 139, 230, 0.3);
|
||||||
|
|
||||||
|
@import "react-big-calendar/lib/sass/styles";
|
||||||
|
|
||||||
|
.rbc-overlay, .rbc-overlay-header{
|
||||||
|
border-color: var(--border-color);
|
||||||
|
background-color: var(--mantine-color-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rbc-show-more{
|
||||||
|
background-color: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rbc-toolbar {
|
||||||
|
gap: var(--gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rbc-btn-group {
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
gap: var(--gap);
|
||||||
|
|
||||||
|
& > button {
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: none !important;
|
||||||
|
border-radius: 0.25rem !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
font-size: var(--mantine-font-size-sm);
|
||||||
|
color: var(--button-color);
|
||||||
|
|
||||||
|
&.rbc-active {
|
||||||
|
color: $color !important;
|
||||||
|
background-color: $color-primary !important;
|
||||||
|
border-color: $active-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $color !important;
|
||||||
|
background-color: $color-primary !important;
|
||||||
|
border-color: $active-border;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,259 @@
|
||||||
|
import {EventModel} from "../../../models/EventTypes.ts";
|
||||||
|
import {useDebouncedValue, useDisclosure, useToggle} from "@mantine/hooks";
|
||||||
|
import classes from "./index.module.css";
|
||||||
|
import PBAvatar from "../../../components/PBAvatar.tsx";
|
||||||
|
import {Link} from "react-router-dom";
|
||||||
|
import {areDatesSame, humanDeltaFromNow, pprintDate} from "../../../lib/datetime.ts";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Box,
|
||||||
|
Center,
|
||||||
|
Group,
|
||||||
|
LoadingOverlay,
|
||||||
|
Pagination,
|
||||||
|
rem,
|
||||||
|
Switch,
|
||||||
|
TextInput,
|
||||||
|
ThemeIcon,
|
||||||
|
Tooltip
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
IconArchive,
|
||||||
|
IconChevronUp,
|
||||||
|
IconHistory,
|
||||||
|
IconHistoryOff,
|
||||||
|
IconInfoSmall,
|
||||||
|
IconSearch,
|
||||||
|
IconSortAscending,
|
||||||
|
IconSortDescending,
|
||||||
|
IconUserStar
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import InnerHtml from "../../../components/InnerHtml";
|
||||||
|
import {usePB} from "../../../lib/pocketbase.tsx";
|
||||||
|
import {useState} from "react";
|
||||||
|
import {useQuery} from "@tanstack/react-query";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This component displays a single event in a row. It displays the event's name, date, location and a collapsable description.
|
||||||
|
* @param event
|
||||||
|
*/
|
||||||
|
const EventRow = ({event}: { event: EventModel }) => {
|
||||||
|
|
||||||
|
const {user} = usePB()
|
||||||
|
|
||||||
|
const [opened, handlers] = useDisclosure(false)
|
||||||
|
|
||||||
|
const delta = humanDeltaFromNow(event.startDate, event.endDate)
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<div className={classes.eventRow}>
|
||||||
|
<div className={classes.row}>
|
||||||
|
<div>
|
||||||
|
<PBAvatar model={event} name={event.name} img={event.img}/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={classes.eventCell}>
|
||||||
|
<Link to={`/events/${event.id}`} className={classes.eventLink}>
|
||||||
|
{event.name}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={classes.eventCell}>
|
||||||
|
{areDatesSame(event.startDate, event.endDate) ?
|
||||||
|
pprintDate(event.startDate)
|
||||||
|
:
|
||||||
|
`${pprintDate(event.startDate)} - ${pprintDate(event.endDate)}`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={classes.eventCell} data-delta={delta.delta}>
|
||||||
|
{
|
||||||
|
delta.message
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={classes.eventCell}>
|
||||||
|
{event.location}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={classes.eventCell}>
|
||||||
|
{event.isStuveEvent ? "StuVe" : "Extern"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{
|
||||||
|
event.adminMembers.length === 0 ? (
|
||||||
|
<Tooltip label={"Dieses Event ist archiviert"} position={"left"} color={"orange"} withArrow>
|
||||||
|
<ThemeIcon
|
||||||
|
color={"orange"}
|
||||||
|
variant={"transparent"}
|
||||||
|
aria-label={"event is archived"}
|
||||||
|
size={"xs"}
|
||||||
|
mr={"sm"}
|
||||||
|
>
|
||||||
|
<IconArchive/>
|
||||||
|
</ThemeIcon>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
|
||||||
|
event.adminMembers.includes(user?.id ?? "") ? (
|
||||||
|
<Tooltip label={"Du bist Event Admin"} position={"left"} color={"green"} withArrow>
|
||||||
|
<ThemeIcon
|
||||||
|
color={"green"}
|
||||||
|
variant={"transparent"}
|
||||||
|
aria-label={"event is active"}
|
||||||
|
size={"xs"}
|
||||||
|
mr={"sm"}
|
||||||
|
>
|
||||||
|
<IconUserStar/>
|
||||||
|
</ThemeIcon>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<ThemeIcon
|
||||||
|
variant={"transparent"}
|
||||||
|
aria-label={"spacer"}
|
||||||
|
size={"xs"}
|
||||||
|
mr={"sm"}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
<Tooltip label={"Mehr Infos"} position={"left"} withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
variant={"transparent"}
|
||||||
|
aria-label={"show info"}
|
||||||
|
onClick={handlers.toggle}
|
||||||
|
disabled={!event.description}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
opened ? <IconChevronUp/> : <IconInfoSmall/>
|
||||||
|
}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{opened && <>
|
||||||
|
<InnerHtml className={classes.descriptionContainer} html={event.description ?? ""}/>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This component displays a list of events. It uses the useQuery hook to fetch events from the database.
|
||||||
|
* The events are displayed in a Table component. The user can search for events and toggle the display of past events.
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
export const EventList = () => {
|
||||||
|
|
||||||
|
const {pb} = usePB()
|
||||||
|
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
const [debouncedSearch] = useDebouncedValue(search, 200);
|
||||||
|
|
||||||
|
const [showPast, setShowPast] = useState(false)
|
||||||
|
|
||||||
|
const [sort, toggleSort] = useToggle(['startDate', '-startDate']);
|
||||||
|
|
||||||
|
const [activePage, setPage] = useState(1);
|
||||||
|
|
||||||
|
const eventsQuery = useQuery({
|
||||||
|
queryKey: ["events", `search=${debouncedSearch}`, `showPast=${showPast}`, `page=${activePage}`, `sort=${sort}`],
|
||||||
|
queryFn: async () => {
|
||||||
|
|
||||||
|
const filter: string[] = []
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
filter.push(`name ~ "${debouncedSearch}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!showPast && !debouncedSearch) {
|
||||||
|
filter.push(`endDate >= "${dayjs().startOf("day").toISOString()}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return await pb.collection("events").getList(activePage, 10, {
|
||||||
|
sort: sort,
|
||||||
|
filter: [`hideFromPublic = false`, ...filter].join(" && ")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<h2>
|
||||||
|
{
|
||||||
|
search ? `Suchergebnisse für '${search}' (${eventsQuery.data?.totalItems ?? 0})` :
|
||||||
|
showPast ? `Alle Events (${eventsQuery.data?.totalItems ?? 0})` : `Events (${eventsQuery.data?.totalItems ?? 0})`
|
||||||
|
}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<Group align={"center"} mb={"sm"}>
|
||||||
|
<TextInput
|
||||||
|
style={{flex: 1}}
|
||||||
|
placeholder={"Nach Events suchen"}
|
||||||
|
aria-label="Search events"
|
||||||
|
leftSection={<IconSearch/>}
|
||||||
|
rightSection={
|
||||||
|
eventsQuery.isLoading &&
|
||||||
|
<Switch
|
||||||
|
size={"xs"}
|
||||||
|
mr={"md"}
|
||||||
|
checked={showPast}
|
||||||
|
onChange={(event) => setShowPast(event.currentTarget.checked)}
|
||||||
|
offLabel={<IconHistoryOff style={{width: rem(10), height: rem(10)}}/>}
|
||||||
|
onLabel={<IconHistory style={{width: rem(10), height: rem(10)}}/>}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
<Tooltip label={"Nach Startdatum sortieren"}>
|
||||||
|
<ActionIcon
|
||||||
|
variant={"transparent"}
|
||||||
|
onClick={() => toggleSort()}
|
||||||
|
aria-label={"sort events by date"}
|
||||||
|
size={"sm"}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
sort === "-startDate" ? <IconSortAscending/> : <IconSortDescending/>
|
||||||
|
}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip label={"Vergangene Events Anzeigen"}>
|
||||||
|
<ActionIcon
|
||||||
|
variant={"transparent"}
|
||||||
|
onClick={() => setShowPast(!showPast)}
|
||||||
|
aria-label={"show past events"}
|
||||||
|
size={"sm"}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
showPast ? <IconHistory/> : <IconHistoryOff/>
|
||||||
|
}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
|
||||||
|
<Box
|
||||||
|
style={{position: "relative"}}
|
||||||
|
mb={"sm"}
|
||||||
|
>
|
||||||
|
<LoadingOverlay visible={eventsQuery.isFetching}/>
|
||||||
|
|
||||||
|
|
||||||
|
<div className={classes.eventsList}>
|
||||||
|
{eventsQuery.data?.items.map(event => {
|
||||||
|
return <EventRow event={event} key={event.id}/>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Center>
|
||||||
|
<Pagination value={activePage} onChange={setPage} total={eventsQuery.data?.totalPages ?? 10}/>
|
||||||
|
</Center>
|
||||||
|
</>
|
||||||
|
}
|
|
@ -0,0 +1,59 @@
|
||||||
|
.eventsList {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
& > :not(:last-child) {
|
||||||
|
border-bottom: var(--border)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.eventRow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--gap);
|
||||||
|
padding: calc(var(--gap) / 2);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
/*noinspection CssInvalidFunction*/
|
||||||
|
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.eventLink {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mantine-primary-color-filled) !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: var(--mantine-primary-color-filled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: var(--gap);
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.descriptionContainer {
|
||||||
|
margin-left: 19px;
|
||||||
|
padding-left: 19px;
|
||||||
|
|
||||||
|
border-left: var(--border)
|
||||||
|
}
|
||||||
|
|
||||||
|
.eventCell {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
&[data-delta="PAST"] {
|
||||||
|
color: var(--mantine-color-red-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-delta="NOW"] {
|
||||||
|
color: var(--mantine-color-green-6);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,59 @@
|
||||||
|
import {IconConfetti, IconPlus} from "@tabler/icons-react";
|
||||||
|
import {EventCalendar} from "./eventCalendar.tsx";
|
||||||
|
import {EventList} from "./eventList.tsx";
|
||||||
|
import {Anchor, Breadcrumbs, Button} from "@mantine/core";
|
||||||
|
import {usePB} from "../../../lib/pocketbase.tsx";
|
||||||
|
import {useDisclosure} from "@mantine/hooks";
|
||||||
|
import EditEventModal from "../EditEventModal.tsx";
|
||||||
|
import {Link} from "react-router-dom";
|
||||||
|
|
||||||
|
|
||||||
|
export default function EventOverview() {
|
||||||
|
|
||||||
|
const {user} = usePB()
|
||||||
|
const [showNewEventModal, showNewEventModalHandler] = useDisclosure(false)
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<div className={"section-transparent"}>
|
||||||
|
<Breadcrumbs>{[
|
||||||
|
<Anchor component={Link} to={"/"}>
|
||||||
|
Home
|
||||||
|
</Anchor>,
|
||||||
|
<Anchor component={Link} to={"/events"}>
|
||||||
|
Events
|
||||||
|
</Anchor>,
|
||||||
|
]}</Breadcrumbs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={"section"}>
|
||||||
|
<div className={"section-icon"}>
|
||||||
|
<IconConfetti/>
|
||||||
|
</div>
|
||||||
|
<h1>Events</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user &&
|
||||||
|
<>
|
||||||
|
<div className={`section-transparent group`} style={{gap: 0}}>
|
||||||
|
<Button
|
||||||
|
leftSection={<IconPlus/>}
|
||||||
|
variant={"transparent"}
|
||||||
|
onClick={showNewEventModalHandler.toggle}
|
||||||
|
color={"green"}
|
||||||
|
>
|
||||||
|
Neues Event erstellen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<EditEventModal opened={showNewEventModal} onClose={showNewEventModalHandler.toggle}/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div className={"section"}>
|
||||||
|
<EventCalendar/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={"section"}>
|
||||||
|
<EventList/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
|
@ -0,0 +1,95 @@
|
||||||
|
import {Link, Outlet, Route, Routes, useParams} from "react-router-dom";
|
||||||
|
import NotFound from "../not-found/index.page.tsx";
|
||||||
|
import EventOverview from "./eventOverview/index.page.tsx";
|
||||||
|
import {usePB} from "../../lib/pocketbase.tsx";
|
||||||
|
import {useQuery} from "@tanstack/react-query";
|
||||||
|
import {Alert, Anchor, Breadcrumbs, Button, LoadingOverlay} from "@mantine/core";
|
||||||
|
import EventView from "./:eventId/index.page.tsx";
|
||||||
|
import EventListView from "./:eventId/l/:listId.page.tsx";
|
||||||
|
|
||||||
|
const SubRouter = () => {
|
||||||
|
const {pb} = usePB()
|
||||||
|
const {eventId} = useParams() as { eventId: string }
|
||||||
|
|
||||||
|
const eventQuery = useQuery({
|
||||||
|
queryKey: ["event", eventId],
|
||||||
|
queryFn: async () => (await pb.collection("events").getOne(eventId, {
|
||||||
|
expand: "adminMembers"
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
if (eventQuery.isLoading) {
|
||||||
|
return <LoadingOverlay/>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventQuery.isError || !eventQuery.data) {
|
||||||
|
return <NotFound/>
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = eventQuery.data
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Routes>
|
||||||
|
<Route index element={<EventView event={event} refetchEvent={eventQuery.refetch}/>}/>
|
||||||
|
|
||||||
|
<Route path={"/l/:listId"} element={<EventListView/>}/>
|
||||||
|
|
||||||
|
<Route path={"/terms-and-conditions"} element={<>
|
||||||
|
<div className={"section-transparent"}>
|
||||||
|
<Breadcrumbs>{[
|
||||||
|
<Anchor component={Link} to={"/"}>
|
||||||
|
Home
|
||||||
|
</Anchor>,
|
||||||
|
<Anchor component={Link} to={"/events"}>
|
||||||
|
Events
|
||||||
|
</Anchor>,
|
||||||
|
<Anchor component={Link} to={`/events/${event.id}`}>
|
||||||
|
{event.name}
|
||||||
|
</Anchor>,
|
||||||
|
<Anchor component={Link} to={`/events/${event.id}/terms-and-conditions`}>
|
||||||
|
Event AGB
|
||||||
|
</Anchor>
|
||||||
|
]}</Breadcrumbs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={"section"}>
|
||||||
|
<h1>Event AGB - {event.name}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
event.isStuveEvent && (
|
||||||
|
<Alert className={"section-transparent"} p={"sm"}>
|
||||||
|
|
||||||
|
<h2>Info</h2>
|
||||||
|
|
||||||
|
Diese Veranstaltung wird von der Studierendenschaft der Universität Ulm organisiert.
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
<Button
|
||||||
|
variant={"light"}
|
||||||
|
component={Link} to={"/terms-and-conditions"} target={"_blank"}
|
||||||
|
>
|
||||||
|
AGB der StuVe
|
||||||
|
</Button>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
<div className={"section"}>
|
||||||
|
{event.additionalAgb}
|
||||||
|
</div>
|
||||||
|
</>}/>
|
||||||
|
</Routes>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EventRouter() {
|
||||||
|
return <>
|
||||||
|
<Routes>
|
||||||
|
<Route index element={<EventOverview/>}/>
|
||||||
|
<Route path={":eventId/*"} element={<SubRouter/>}/>
|
||||||
|
<Route path={"*"} element={<NotFound/>}/>
|
||||||
|
</Routes>
|
||||||
|
<Outlet/>
|
||||||
|
</>
|
||||||
|
}
|
|
@ -0,0 +1,166 @@
|
||||||
|
import {IconHome} from "@tabler/icons-react";
|
||||||
|
import FormBuilder from "../../components/formUtil/formBuilder";
|
||||||
|
import {useState} from "react";
|
||||||
|
import {FormSchema} from "../../components/formUtil/formBuilder/types.ts";
|
||||||
|
import FormInput from "../../components/formUtil/fromInput";
|
||||||
|
|
||||||
|
const exampleForm: FormSchema = {
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"id": "email",
|
||||||
|
"type": "email",
|
||||||
|
"label": "Email",
|
||||||
|
"description": "meine Email",
|
||||||
|
"placeholder": "Email",
|
||||||
|
"required": true,
|
||||||
|
"validator": {
|
||||||
|
"allowedDomains": []
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"multiline": false,
|
||||||
|
"validator": {
|
||||||
|
"minLength": null,
|
||||||
|
"maxLength": null,
|
||||||
|
"regex": ""
|
||||||
|
},
|
||||||
|
"id": "mantine-3q2h2a06s",
|
||||||
|
"label": "Text",
|
||||||
|
"description": "Text",
|
||||||
|
"placeholder": "Text",
|
||||||
|
"required": false,
|
||||||
|
"meta": {
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"multiline": true,
|
||||||
|
"validator": {
|
||||||
|
"minLength": null,
|
||||||
|
"maxLength": null,
|
||||||
|
"regex": ""
|
||||||
|
},
|
||||||
|
"id": "mantine-2r0vyxv46",
|
||||||
|
"label": "Textarea",
|
||||||
|
"description": "Textarea",
|
||||||
|
"placeholder": "Textarea",
|
||||||
|
"required": false,
|
||||||
|
"meta": {
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "number",
|
||||||
|
"validator": {
|
||||||
|
"min": null,
|
||||||
|
"max": null
|
||||||
|
},
|
||||||
|
"id": "mantine-2ph0q6c26",
|
||||||
|
"label": "Zahl",
|
||||||
|
"description": "Zahl",
|
||||||
|
"placeholder": "Zahl",
|
||||||
|
"required": true,
|
||||||
|
"meta": {
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "date",
|
||||||
|
"validator": {
|
||||||
|
"minDate": null,
|
||||||
|
"maxDate": null
|
||||||
|
},
|
||||||
|
"id": "mantine-x10uvaur7",
|
||||||
|
"label": "Datum",
|
||||||
|
"description": "Datum",
|
||||||
|
"placeholder": "Datum",
|
||||||
|
"required": false,
|
||||||
|
"meta": {
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "select",
|
||||||
|
"multiple": true,
|
||||||
|
"options": [
|
||||||
|
"Option 1",
|
||||||
|
"Option 2"
|
||||||
|
],
|
||||||
|
"id": "mantine-g3jxdnm5t",
|
||||||
|
"label": "Mehrfachauswahl",
|
||||||
|
"description": "Mehrfachauswahl",
|
||||||
|
"placeholder": "Mehrfachauswahl",
|
||||||
|
"required": false,
|
||||||
|
"meta": {
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "select",
|
||||||
|
"multiple": false,
|
||||||
|
"options": [
|
||||||
|
"Option 1",
|
||||||
|
"Option 2"
|
||||||
|
],
|
||||||
|
"id": "mantine-rjwnnhpn2",
|
||||||
|
"label": "Einzelauswahl",
|
||||||
|
"description": "",
|
||||||
|
"placeholder": "",
|
||||||
|
"required": false,
|
||||||
|
"meta": {
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "checkbox",
|
||||||
|
"id": "mantine-vwkmssuvx",
|
||||||
|
"label": "Checkbox",
|
||||||
|
"description": "",
|
||||||
|
"placeholder": "",
|
||||||
|
"required": false,
|
||||||
|
"meta": {
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
|
||||||
|
const [value, setValues] = useState<FormSchema>()
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<div className={"section"}>
|
||||||
|
<IconHome className={"section-icon"}/>
|
||||||
|
<h1>Home Page</h1>
|
||||||
|
</div>
|
||||||
|
<div className={"section"}>
|
||||||
|
<h2>Content</h2>
|
||||||
|
<FormBuilder
|
||||||
|
onSubmit={(values) => {
|
||||||
|
setValues(values)
|
||||||
|
}}
|
||||||
|
withPreview
|
||||||
|
initialValues={exampleForm}
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={"section"}>
|
||||||
|
<FormInput schema={value ?? {fields: []}}/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={"section"}>
|
||||||
|
<h2>Schema</h2>
|
||||||
|
<pre>
|
||||||
|
{JSON.stringify(value, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
|
@ -1,22 +0,0 @@
|
||||||
export default function HomePage() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1>Home Page</h1>
|
|
||||||
|
|
||||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum. Quisquam, voluptatum. Quisquam,
|
|
||||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum. Quisquam, voluptatum. Quisquam,
|
|
||||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum. Quisquam, voluptatum. Quisquam,
|
|
||||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum. Quisquam, voluptatum. Quisquam,
|
|
||||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum. Quisquam, voluptatum. Quisquam,
|
|
||||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum. Quisquam, voluptatum. Quisquam,
|
|
||||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum. Quisquam, voluptatum. Quisquam,
|
|
||||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum. Quisquam, voluptatum. Quisquam,
|
|
||||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum. Quisquam, voluptatum. Quisquam,
|
|
||||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum. Quisquam, voluptatum. Quisquam,
|
|
||||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum. Quisquam, voluptatum. Quisquam,
|
|
||||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum. Quisquam, voluptatum. Quisquam,
|
|
||||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum. Quisquam, voluptatum. Quisquam,
|
|
||||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum. Quisquam, voluptatum. Quisquam,
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,8 +1,6 @@
|
||||||
import classes from "./index.module.css"
|
import classes from "./index.module.css"
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.container}>
|
<div className={classes.container}>
|
||||||
<h1 className={classes.status}>404</h1>
|
<h1 className={classes.status}>404</h1>
|
|
@ -0,0 +1,20 @@
|
||||||
|
import {useSettings} from "../lib/settings.ts";
|
||||||
|
import {LoadingOverlay} from "@mantine/core";
|
||||||
|
import InnerHtml from "../components/InnerHtml";
|
||||||
|
import {pprintDate} from "../lib/datetime.ts";
|
||||||
|
|
||||||
|
export default function PrivacyPolicy() {
|
||||||
|
const settings = useSettings()
|
||||||
|
|
||||||
|
if (!settings) return <LoadingOverlay visible={true}/>
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<div className={"section"}>
|
||||||
|
Zuletzt Bearbeitet: {pprintDate(settings.privacyPolicy.updated)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={"section"}>
|
||||||
|
<InnerHtml html={settings.privacyPolicy.value}/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
import {useSettings} from "../lib/settings.ts";
|
||||||
|
import {LoadingOverlay} from "@mantine/core";
|
||||||
|
import InnerHtml from "../components/InnerHtml";
|
||||||
|
import {pprintDate} from "../lib/datetime.ts";
|
||||||
|
|
||||||
|
export default function TermsAndConditions() {
|
||||||
|
const settings = useSettings()
|
||||||
|
|
||||||
|
if (!settings) return <LoadingOverlay visible={true}/>
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<div className={"section"}>
|
||||||
|
Zuletzt Bearbeitet: {pprintDate(settings.agb.updated)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={"section"}>
|
||||||
|
<InnerHtml html={settings.agb.value}/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
width: 100%;
|
||||||
|
gap: var(--mantine-spacing-lg);
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
@media (min-width: $mantine-breakpoint-sm) {
|
||||||
|
flex-basis: 35%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: $mantine-breakpoint-lg) {
|
||||||
|
flex-basis: 20%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
display: flex;
|
||||||
|
max-width: 150px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 0 5px 0 magenta;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,142 @@
|
||||||
|
import {useForm} from "@mantine/form";
|
||||||
|
import {Button, ColorInput, NumberInput, Select, TextInput, Tooltip} from "@mantine/core";
|
||||||
|
import classes from "./index.module.css";
|
||||||
|
import {createQRCodeUrl, ErrorCorrectionLevel} from "../../../lib/util.ts";
|
||||||
|
import {CodeHighlight} from "@mantine/code-highlight";
|
||||||
|
import {IconDownload, IconQrcode} from "@tabler/icons-react";
|
||||||
|
|
||||||
|
export default function QRCodeGenerator() {
|
||||||
|
|
||||||
|
const formValues = useForm({
|
||||||
|
initialValues: {
|
||||||
|
data: "",
|
||||||
|
ecc: "M" as ErrorCorrectionLevel,
|
||||||
|
scale: 1,
|
||||||
|
border: 1,
|
||||||
|
color: "#000000",
|
||||||
|
colorBackground: "#ffffff"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const qrCodeLink = createQRCodeUrl(formValues.values)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={"section"}>
|
||||||
|
<div className={"section-icon"}>
|
||||||
|
<IconQrcode/>
|
||||||
|
</div>
|
||||||
|
<h1>QR Code Generator</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={"section"}>
|
||||||
|
<h2>How to</h2>
|
||||||
|
<p>
|
||||||
|
Mit diesem Tool kannst du einen QR Code generieren. Diesen kannst du dann im Wiki oder im Pad
|
||||||
|
einbetten. Das heißt, dass der QR Code dann auf dem Pad oder im Wiki angezeigt wird ohne ihn als
|
||||||
|
Datei hochladen zu müssen.
|
||||||
|
Es ist möglich, die Größe des QR Codes sowie die Farben anzupassen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`section`}>
|
||||||
|
<h2>Daten</h2>
|
||||||
|
|
||||||
|
<div className={`${classes.container}`}>
|
||||||
|
<TextInput
|
||||||
|
label={"Daten"}
|
||||||
|
autoFocus={true}
|
||||||
|
description={"z.B. URL, Text, etc."}
|
||||||
|
placeholder={"Tippe etwas ein um einen QR Code zu generieren ..."}
|
||||||
|
required
|
||||||
|
{...formValues.getInputProps("data")}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label={"Fehlerkorrektur"}
|
||||||
|
description={"Wie stark soll die Fehlerkorrektur sein?"}
|
||||||
|
placeholder={"Wähle die Fehlerkorrektur"}
|
||||||
|
data={[
|
||||||
|
{value: 'L', label: 'Gering'},
|
||||||
|
{value: 'M', label: 'Mittel'},
|
||||||
|
{value: 'Q', label: 'Hoch'},
|
||||||
|
{value: 'H', label: 'Sehr hoch'},
|
||||||
|
]}
|
||||||
|
{...formValues.getInputProps("ecc")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label="Skalierung"
|
||||||
|
description="Die Größe des QR Codes"
|
||||||
|
placeholder="Skalierung"
|
||||||
|
min={1}
|
||||||
|
{...formValues.getInputProps("scale")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label="Rand"
|
||||||
|
description="Der Rand des QR Codes"
|
||||||
|
placeholder="Rand"
|
||||||
|
min={0}
|
||||||
|
{...formValues.getInputProps("border")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ColorInput
|
||||||
|
label={"Farbe"}
|
||||||
|
description={"Die Farbe des QR Codes"}
|
||||||
|
placeholder={"Farbe"}
|
||||||
|
{...formValues.getInputProps("color")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ColorInput
|
||||||
|
label={"Hintergrundfarbe"}
|
||||||
|
description={"Die Hintergrundfarbe des QR Codes"}
|
||||||
|
placeholder={"Hintergrundfarbe"}
|
||||||
|
{...formValues.getInputProps("colorBackground")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formValues.values.data && (
|
||||||
|
<Tooltip
|
||||||
|
multiline
|
||||||
|
withArrow
|
||||||
|
color={"blue"}
|
||||||
|
label={<>
|
||||||
|
Um den QR Code ins Wiki oder ins Pad einzubinden den Link unten verwenden
|
||||||
|
</>}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
component={"a"}
|
||||||
|
href={qrCodeLink}
|
||||||
|
leftSection={<IconDownload/>}
|
||||||
|
mt={"sm"}
|
||||||
|
>
|
||||||
|
Download SVG
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formValues.values.data && (
|
||||||
|
<div className={`section`}>
|
||||||
|
<h2>Vorschau</h2>
|
||||||
|
|
||||||
|
<div className={classes.preview}>
|
||||||
|
<img src={qrCodeLink} alt={"QR Code Vorschau"}/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Markdown Link</h2>
|
||||||
|
|
||||||
|
<CodeHighlight code={
|
||||||
|
`![QR Code for ${formValues.values.data}](${qrCodeLink})`
|
||||||
|
} language="markdown"/>
|
||||||
|
|
||||||
|
<h2>HTML Image Tag</h2>
|
||||||
|
|
||||||
|
<CodeHighlight code={
|
||||||
|
`<img src="${qrCodeLink}" alt="QR Code for ${formValues.values.data}"/>`
|
||||||
|
} language="html"/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,8 +1,7 @@
|
||||||
import { defineConfig } from 'vite'
|
import {defineConfig} from 'vite'
|
||||||
import react from '@vitejs/plugin-react-swc'
|
import react from '@vitejs/plugin-react-swc'
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()]
|
||||||
})
|
})
|
||||||
|
|
Loading…
Reference in New Issue