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
|
||||
*/
|
||||
import {IconHome, IconInfoCircle, IconShovel, TablerIconsProps} from "@tabler/icons-react";
|
||||
import {IconHome, IconInfoCircle, IconQrcode, TablerIconsProps} from "@tabler/icons-react";
|
||||
import {ReactNode} from "react";
|
||||
|
||||
// POCKETBASE
|
||||
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"
|
||||
|
||||
// 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
|
||||
export const NAV_ITEMS = [
|
||||
{
|
||||
section: "General",
|
||||
section: "Seiten",
|
||||
items: [
|
||||
{
|
||||
title: "Home",
|
||||
icon: IconHome,
|
||||
description: "Home",
|
||||
link: "/"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
section: "Events",
|
||||
items: [
|
||||
},
|
||||
{
|
||||
title: "Übersicht",
|
||||
title: "Events",
|
||||
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"
|
||||
},
|
||||
{
|
||||
title: "Listen",
|
||||
icon: IconShovel,
|
||||
description: "Administration für StuVe Events. Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum. Quisquam, voluptatum. Quisquam,",
|
||||
link: "/events/lists"
|
||||
title: "QR Code Generator",
|
||||
icon: IconQrcode,
|
||||
description: "Generiere einen QR Code",
|
||||
link: "/util/qr"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
] as {
|
||||
section: string,
|
||||
items: {
|
||||
|
|
36
package.json
36
package.json
|
@ -12,23 +12,46 @@
|
|||
"dependencies": {
|
||||
"@fontsource/fira-code": "^5.0.15",
|
||||
"@fontsource/overpass": "^5.0.15",
|
||||
"@mantine/core": "^7.1.5",
|
||||
"@mantine/form": "^7.1.5",
|
||||
"@mantine/hooks": "^7.1.5",
|
||||
"@mantine/notifications": "^7.1.5",
|
||||
"@hello-pangea/dnd": "^16.6.0",
|
||||
"@mantine/code-highlight": "^7.8.0",
|
||||
"@mantine/core": "^7.8.0",
|
||||
"@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",
|
||||
"@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",
|
||||
"ms": "^2.1.3",
|
||||
"pocketbase": "^0.19.0",
|
||||
"react": "^18.2.0",
|
||||
"react-big-calendar": "^1.11.3",
|
||||
"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": {
|
||||
"@types/ms": "^0.7.33",
|
||||
"@types/react": "^18.2.15",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@types/sanitize-html": "^2.11.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"@vitejs/plugin-react-swc": "^3.3.2",
|
||||
|
@ -36,8 +59,9 @@
|
|||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.3",
|
||||
"postcss": "^8.4.31",
|
||||
"postcss-preset-mantine": "^1.9.0",
|
||||
"postcss-preset-mantine": "^1.13.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"sass": "^1.75.0",
|
||||
"typescript": "^5.0.2",
|
||||
"vite": "^4.4.5"
|
||||
}
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
import {createBrowserRouter, RouterProvider} from "react-router-dom";
|
||||
import HomePage from "./pages/home";
|
||||
import NotFound from "./pages/not-found";
|
||||
import HomePage from "./pages/home/index.page.tsx";
|
||||
import NotFound from "./pages/not-found/index.page.tsx";
|
||||
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([
|
||||
{
|
||||
|
@ -12,6 +16,31 @@ const router = createBrowserRouter([
|
|||
index: true,
|
||||
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: "*",
|
||||
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;
|
||||
position: fixed;
|
||||
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 {
|
||||
padding: var(--padding);
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
}
|
||||
padding-top: var(--gap);
|
||||
|
||||
& > * {
|
||||
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 classes from "./index.module.css";
|
||||
import Footer from "./footer";
|
||||
|
||||
export default function Layout() {
|
||||
return <div className={classes.container}>
|
||||
<NavBar/>
|
||||
|
||||
<div className={`${classes.content} scrollbar`}>
|
||||
<Outlet/>
|
||||
<div className={`${classes.body} no-scrollbar`}>
|
||||
<div className={`${classes.content}`}>
|
||||
<Outlet/>
|
||||
</div>
|
||||
<Footer/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
|
@ -1,10 +1,11 @@
|
|||
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 {usePB} from "../../lib/pocketbase.tsx";
|
||||
import {usePB} from "../../../lib/pocketbase.tsx";
|
||||
import {useForm} from "@mantine/form";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import classes from "./index.module.css";
|
||||
import {Link} from "react-router-dom";
|
||||
|
||||
/**
|
||||
* This component renders a login button and a login modal.
|
||||
|
@ -17,7 +18,8 @@ export default function Login() {
|
|||
const formValues = useForm({
|
||||
initialValues: {
|
||||
username: "",
|
||||
password: ""
|
||||
password: "",
|
||||
privacy: false
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -33,8 +35,7 @@ export default function Login() {
|
|||
className={classes.stack}
|
||||
onSubmit={formValues.onSubmit(() => loginMutation.mutate())}
|
||||
>
|
||||
|
||||
<Title order={3}>Login mit StuVe IT Account</Title>
|
||||
<Title ta={"center"} order={3}>StuVe IT Account - Login</Title>
|
||||
|
||||
<TextInput
|
||||
label={"Anmeldename"}
|
||||
|
@ -48,6 +49,22 @@ export default function Login() {
|
|||
{...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 && (
|
||||
<Alert variant="transparent" color="red" title="Fehler" icon={<IconExclamationCircle/>}>
|
||||
{loginMutation.error.message}
|
||||
|
@ -56,7 +73,7 @@ export default function Login() {
|
|||
|
||||
<Button
|
||||
loading={loginMutation.isPending}
|
||||
disabled={formValues.values.username === "" || formValues.values.password === ""}
|
||||
disabled={formValues.values.username === "" || formValues.values.password === "" || !formValues.values.privacy}
|
||||
type={"submit"}
|
||||
>
|
||||
Einloggen
|
|
@ -1,5 +1,5 @@
|
|||
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 {Fragment} from "react";
|
||||
|
|
@ -1,16 +1,9 @@
|
|||
import {ActionIcon, Button, Divider, List, Modal, rem, Text, ThemeIcon, Title} from "@mantine/core";
|
||||
import {
|
||||
IconBalloon,
|
||||
IconCalendar,
|
||||
IconId,
|
||||
IconLogout,
|
||||
IconServer,
|
||||
IconServerOff,
|
||||
IconUsersGroup
|
||||
} from "@tabler/icons-react";
|
||||
import {ActionIcon, Button, Code, Divider, Modal, Text, ThemeIcon, Title} from "@mantine/core";
|
||||
import {IconBalloon, IconCalendar, IconId, IconLogout, IconServer, IconServerOff} from "@tabler/icons-react";
|
||||
import {useDisclosure} from "@mantine/hooks";
|
||||
import {usePB} from "../../lib/pocketbase.tsx";
|
||||
import {usePB} from "../../../lib/pocketbase.tsx";
|
||||
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.
|
||||
|
@ -34,9 +27,7 @@ export default function UserMenu() {
|
|||
<IconId/>
|
||||
</ThemeIcon>
|
||||
|
||||
<Text>
|
||||
ID {user?.uidNumber}
|
||||
</Text>
|
||||
<Code>{user?.id}</Code>
|
||||
</div>
|
||||
|
||||
<div className={classes.row}>
|
||||
|
@ -50,8 +41,8 @@ export default function UserMenu() {
|
|||
<Text>
|
||||
{user?.accountExpires ? (
|
||||
|
||||
user?.accountExpires?.getTime() > Date.now() ? (
|
||||
"Account ist aktiv und läuft am " + user?.accountExpires?.toLocaleDateString() + " ab"
|
||||
new Date(user?.accountExpires).getTime() > Date.now() ? (
|
||||
"Account ist aktiv und läuft am " + new Date(user?.accountExpires).toLocaleDateString() + " ab"
|
||||
) : (
|
||||
"Account ist abgelaufen"
|
||||
)
|
||||
|
@ -82,33 +73,17 @@ export default function UserMenu() {
|
|||
)}
|
||||
|
||||
<Text>
|
||||
{apiIsHealthy ? "API ist erreichbar" : "API ist nicht erreichbar"}
|
||||
{apiIsHealthy ? "Das Backend ist erreichbar" : "Das Backend ist nicht erreichbar"}
|
||||
</Text>
|
||||
</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
|
||||
leftSection={<IconLogout/>}
|
|
@ -1,5 +1,4 @@
|
|||
.navbar {
|
||||
border-bottom: 1px solid var(--mantine-color-gray-4);
|
||||
height: 56px;
|
||||
|
||||
display: flex;
|
||||
|
@ -7,6 +6,8 @@
|
|||
justify-content: space-between;
|
||||
width: 100%;
|
||||
|
||||
box-shadow: var(--shadow);
|
||||
|
||||
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 {ActionIcon, Image, Menu, ThemeIcon, useMantineColorScheme} from "@mantine/core";
|
||||
import {IconChevronDown, IconMoon, IconSun} from "@tabler/icons-react";
|
|
@ -1,7 +1,12 @@
|
|||
:root {
|
||||
* {
|
||||
--gap: var(--mantine-spacing-sm);
|
||||
--padding: var(--mantine-spacing-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 {
|
||||
color: inherit; /* Inherits the color from the parent element */
|
||||
text-decoration: none; /* Removes underline */
|
||||
|
@ -42,4 +58,68 @@ a {
|
|||
a:hover, a:active, a:visited {
|
||||
color: inherit; /* Inherits the color from the parent element */
|
||||
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 {TypedPocketBase} from "../models";
|
||||
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");
|
||||
|
||||
|
@ -76,7 +76,7 @@ const PocketData = () => {
|
|||
return {
|
||||
ldapLogin,
|
||||
logout,
|
||||
user: user as LdapUser | null,
|
||||
user: user as LdapUserModel | null,
|
||||
pb,
|
||||
refreshUser,
|
||||
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 Router from './Router.tsx'
|
||||
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 {QueryClient, QueryClientProvider} from "@tanstack/react-query";
|
||||
import {PocketBaseProvider} from "./lib/pocketbase.tsx";
|
||||
|
@ -24,7 +27,7 @@ export const theme = mergeMantineTheme(DEFAULT_THEME, themeOverride);
|
|||
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<MantineProvider theme={theme} >
|
||||
<MantineProvider theme={theme}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PocketBaseProvider>
|
||||
<React.StrictMode>
|
||||
|
|
|
@ -1,35 +1,33 @@
|
|||
import {RecordModel} from "pocketbase";
|
||||
|
||||
export type LdapUser = {
|
||||
export type LdapUserModel = {
|
||||
|
||||
username: string;
|
||||
email: string;
|
||||
cn: string;
|
||||
dn: string;
|
||||
uidNumber: string;
|
||||
sn: string;
|
||||
givenName: string;
|
||||
accountExpires: Date | null;
|
||||
accountExpires: string | null;
|
||||
memberOf: string[];
|
||||
|
||||
expand: {
|
||||
memberOf: LdapGroup[]
|
||||
memberOf: LdapGroupModel[]
|
||||
}
|
||||
} & RecordModel
|
||||
|
||||
export type LdapGroup = {
|
||||
gidNumber: string;
|
||||
export type LdapGroupModel = {
|
||||
description: string;
|
||||
cn: string;
|
||||
dn: string;
|
||||
memberOf: string[];
|
||||
|
||||
expand: {
|
||||
memberOf: LdapGroup[]
|
||||
memberOf: LdapGroupModel[]
|
||||
}
|
||||
} & RecordModel
|
||||
|
||||
export type LdapSyncLog = {
|
||||
export type LdapSyncLogModel = {
|
||||
usersFound: number;
|
||||
usersSynced: 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 {LdapGroup, LdapSyncLog, LdapUser} from "./AuthTypes.ts";
|
||||
import PocketBase, {RecordModel, RecordService} from "pocketbase";
|
||||
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 {
|
||||
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"
|
||||
|
||||
export default function NotFound() {
|
||||
|
||||
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
<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'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
|
||||
plugins: [react()]
|
||||
})
|
Loading…
Reference in New Issue