feat: FormBuilder

This commit is contained in:
Valentin Kolb 2024-04-14 03:33:44 +02:00
parent 253bb24224
commit f0318195c1
60 changed files with 5518 additions and 223 deletions

View File

@ -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: {

View File

@ -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"
}

View File

@ -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/>

View File

@ -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;
}
}

View File

@ -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>
</>
}

View File

@ -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

View File

@ -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
}
})
}
/>
)
}

View File

@ -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>
</>
}

View File

@ -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
}
})
}
/>
)
}

View File

@ -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>
</>
}

View File

@ -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;
}
}

View File

@ -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>
}

View File

@ -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>
)
}

View File

@ -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[],
}

View File

@ -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;

View File

@ -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>
}

View File

@ -0,0 +1,10 @@
import {FieldType} from "../formBuilder/types.ts";
export type FieldEntry = {
value: unknown
type: FieldType
}
export type FormEntries = {
[key: string]: FieldEntry
}

View File

@ -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,
}
}
}

View File

@ -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;
}
}

View File

@ -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>
)
}

View File

@ -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>
</>
)
}

View File

@ -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>
)
}

View File

@ -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);
}
}
}

View File

@ -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>
)
}

View File

@ -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);
}
}

View File

@ -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>
}

View File

@ -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

View File

@ -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";

View File

@ -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/>}

View File

@ -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);
}

View File

@ -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";

View File

@ -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);
}

76
src/lib/datetime.ts Normal file
View File

@ -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"
}
}

View File

@ -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,

40
src/lib/settings.ts Normal file
View File

@ -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
}

43
src/lib/util.ts Normal file
View File

@ -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)}`
}

View File

@ -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>

View File

@ -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;

61
src/models/EventTypes.ts Normal file
View File

@ -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

View File

@ -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>
}

View File

@ -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;
}
}

View File

@ -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>
</>
}

View File

@ -0,0 +1,7 @@
export default function EventListView() {
return <>
<div className={"section"}>
<h1>List</h1>
</div>
</>
}

View File

@ -0,0 +1,7 @@
export default function EventTermsAndConditions() {
return <>
<div className={"section"}>
<h1>Event AGB</h1>
</div>
</>
}

View File

@ -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>
</>
}

View File

@ -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>
</>
}

View File

@ -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;
}
}
}

View File

@ -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>
</>
}

View File

@ -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);
}
}

View File

@ -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>
</>
}

View File

@ -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/>
</>
}

View File

@ -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>
</>
}

View File

@ -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>
)
}

View File

@ -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>

View File

@ -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>
</>
}

View File

@ -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>
</>
}

View File

@ -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;
}
}

View File

@ -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>
)}
</>
)
}

View File

@ -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()]
})

1942
yarn.lock

File diff suppressed because it is too large Load Diff