diff --git a/config.ts b/config.ts index acfd0ce..436cd2f 100644 --- a/config.ts +++ b/config.ts @@ -1,45 +1,45 @@ /** * @description Global configuration file for the application */ -import {IconHome, IconInfoCircle, IconShovel, TablerIconsProps} from "@tabler/icons-react"; +import {IconHome, IconInfoCircle, IconQrcode, TablerIconsProps} from "@tabler/icons-react"; import {ReactNode} from "react"; // POCKETBASE export const PB_USER_COLLECTION = "ldap_users" -export const PB_BASE_URL = "https://stuve.uni-ulm.de" +export const PB_BASE_URL = "https://it.stuve.uni-ulm.de" export const PB_STORAGE_KEY = "stuve-it-ldap-login" +// general +export const APP_NAME = "StuVe IT" +export const APP_DESCRIPTION = "StuVe IT - Die IT-Abteilung der Studierendenvertretung der Universität Ulm" +export const APP_VERSION = "0.1.0" + // Navigation export const NAV_ITEMS = [ { - section: "General", + section: "Seiten", items: [ { title: "Home", icon: IconHome, description: "Home", link: "/" - } - ] - }, - { - section: "Events", - items: [ + }, { - title: "Übersicht", + title: "Events", icon: IconInfoCircle, - description: "Administration für StuVe Events. Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum. Quisquam, voluptatum. Quisquam,", + description: "Administration für StuVe Events.", link: "/events" }, { - title: "Listen", - icon: IconShovel, - description: "Administration für StuVe Events. Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum. Quisquam, voluptatum. Quisquam,", - link: "/events/lists" + title: "QR Code Generator", + icon: IconQrcode, + description: "Generiere einen QR Code", + link: "/util/qr" } ] - } + }, ] as { section: string, items: { diff --git a/package.json b/package.json index 8fc7e88..77f9a0a 100644 --- a/package.json +++ b/package.json @@ -12,23 +12,46 @@ "dependencies": { "@fontsource/fira-code": "^5.0.15", "@fontsource/overpass": "^5.0.15", - "@mantine/core": "^7.1.5", - "@mantine/form": "^7.1.5", - "@mantine/hooks": "^7.1.5", - "@mantine/notifications": "^7.1.5", + "@hello-pangea/dnd": "^16.6.0", + "@mantine/code-highlight": "^7.8.0", + "@mantine/core": "^7.8.0", + "@mantine/dates": "^7.8.0", + "@mantine/form": "^7.8.0", + "@mantine/hooks": "^7.8.0", + "@mantine/notifications": "^7.8.0", + "@mantine/tiptap": "^7.8.0", "@tabler/icons-react": "^2.39.0", "@tanstack/react-query": "^5.0.5", + "@tiptap/extension-collaboration": "^2.3.0", + "@tiptap/extension-collaboration-cursor": "^2.3.0", + "@tiptap/extension-link": "^2.3.0", + "@tiptap/extension-placeholder": "^2.3.0", + "@tiptap/extension-underline": "^2.3.0", + "@tiptap/pm": "^2.3.0", + "@tiptap/react": "^2.3.0", + "@tiptap/starter-kit": "^2.3.0", + "@types/react-big-calendar": "^1.8.9", + "dayjs": "^1.11.10", "jwt-decode": "^3.1.2", "ms": "^2.1.3", "pocketbase": "^0.19.0", "react": "^18.2.0", + "react-big-calendar": "^1.11.3", "react-dom": "^18.2.0", - "react-router-dom": "^6.20.0" + "react-dropzone": "^14.2.3", + "react-markdown": "^9.0.1", + "react-router-dom": "^6.20.0", + "recoil": "^0.7.7", + "remark-gfm": "^4.0.0", + "sanitize-html": "^2.13.0", + "tiptap": "^1.32.2", + "zod": "^3.22.4" }, "devDependencies": { "@types/ms": "^0.7.33", "@types/react": "^18.2.15", "@types/react-dom": "^18.2.7", + "@types/sanitize-html": "^2.11.0", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", "@vitejs/plugin-react-swc": "^3.3.2", @@ -36,8 +59,9 @@ "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.3", "postcss": "^8.4.31", - "postcss-preset-mantine": "^1.9.0", + "postcss-preset-mantine": "^1.13.0", "postcss-simple-vars": "^7.0.1", + "sass": "^1.75.0", "typescript": "^5.0.2", "vite": "^4.4.5" } diff --git a/src/Router.tsx b/src/Router.tsx index de724b9..0f71ef6 100644 --- a/src/Router.tsx +++ b/src/Router.tsx @@ -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: }, + { + path: "privacy-policy", + element: + }, + { + path: "imprint", + element: + }, + { + path: "terms-and-conditions", + element: + }, + { + path: "events/*", + element: , + }, + { + path: "util", + children: [ + { + path: "qr", + element: + } + ] + }, { path: "*", element: diff --git a/src/components/InnerHtml/index.module.css b/src/components/InnerHtml/index.module.css new file mode 100644 index 0000000..ac5ac4f --- /dev/null +++ b/src/components/InnerHtml/index.module.css @@ -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; + } +} \ No newline at end of file diff --git a/src/components/InnerHtml/index.tsx b/src/components/InnerHtml/index.tsx new file mode 100644 index 0000000..3661f52 --- /dev/null +++ b/src/components/InnerHtml/index.tsx @@ -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 <> + +
+ + +} \ No newline at end of file diff --git a/src/components/PBAvatar.tsx b/src/components/PBAvatar.tsx new file mode 100644 index 0000000..75786ee --- /dev/null +++ b/src/components/PBAvatar.tsx @@ -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 + {name.slice(0, 2).toUpperCase()} + +}) + +PBAvatar.displayName = 'PBAvatar' + +export default PBAvatar \ No newline at end of file diff --git a/src/components/auth/LdapGroupInput.tsx b/src/components/auth/LdapGroupInput.tsx new file mode 100644 index 0000000..c36bafa --- /dev/null +++ b/src/components/auth/LdapGroupInput.tsx @@ -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) { + + const {pb} = usePB() + + return ( + + label={label} + description={description} + selectedRecords={selectedRecords} + setSelectedRecords={setSelectedRecords} + recordToString={(group) => ({displayName: `${group.cn}`, description: group.description})} + placeholder={placeholder || "Suche nach Gruppen ..."} + leftSection={} + recordSearchMutation={ + useMutation({ + mutationFn: async (search: string) => { + if (!search) { + return [] + } + return (await pb.collection('ldap_groups').getList(1, 5, { + filter: `cn ~ "%${search}%"`, + })).items + } + }) + } + /> + ) +} \ No newline at end of file diff --git a/src/components/auth/LdapGroupsDisplay.tsx b/src/components/auth/LdapGroupsDisplay.tsx new file mode 100644 index 0000000..0c3345b --- /dev/null +++ b/src/components/auth/LdapGroupsDisplay.tsx @@ -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 < > + + } + > + { + groups.map((group) => ( + + + {group.cn} + + + )) + } + + +} \ No newline at end of file diff --git a/src/components/auth/UserInput.tsx b/src/components/auth/UserInput.tsx new file mode 100644 index 0000000..e08ef59 --- /dev/null +++ b/src/components/auth/UserInput.tsx @@ -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) { + + const {pb} = usePB() + + return ( + + recordToString={(user) => ({displayName: `${user.givenName} ${user.sn}`})} + placeholder={props.placeholder || "Suche nach Personen..."} + {...props} + leftSection={} + 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 + } + }) + } + /> + ) +} + diff --git a/src/components/auth/UsersDisplay.tsx b/src/components/auth/UsersDisplay.tsx new file mode 100644 index 0000000..8a421ec --- /dev/null +++ b/src/components/auth/UsersDisplay.tsx @@ -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 <> + }> + { + users.map((u) => ( + + {u.givenName} {u.sn} + + )) + } + + +} \ No newline at end of file diff --git a/src/components/formUtil/formBuilder/editField.module.css b/src/components/formUtil/formBuilder/editField.module.css new file mode 100644 index 0000000..024a7b8 --- /dev/null +++ b/src/components/formUtil/formBuilder/editField.module.css @@ -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; + } +} \ No newline at end of file diff --git a/src/components/formUtil/formBuilder/editField.tsx b/src/components/formUtil/formBuilder/editField.tsx new file mode 100644 index 0000000..c9670a7 --- /dev/null +++ b/src/components/formUtil/formBuilder/editField.tsx @@ -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; +} + +/** + * Validation settings for text fields + * Min length, max length and regex + */ +const TextFieldSettings = ({formValues, index}: Omit) => { + return <> + + + + + + +} + +/** + * Validation settings for date fields + * Min date and max date + */ +const DateFieldSettings = ({formValues, index}: Omit) => { + return <> + + + + +} + +/** + * Validation settings for number fields + * Min number and max number + */ +const NumberFieldSettings = ({formValues, index}: Omit) => { + return <> + + + + +} + +/** + * Settings for select fields + * List to add and remove options + */ +const SelectFieldSettings = ({field, formValues, index}: Omit & { field: SelectField }) => { + + const error = formValues.errors?.[`fields.${index}.options`] + + return <> +
+ + {error && {error}} + + {field.options.map((_, optionIndex: number) => { + return { + formValues.removeListItem(`fields.${index}.options`, optionIndex) + }} + > + + + + } + + {...formValues.getInputProps(`fields.${index}.options.${optionIndex}`)} + error={!!error} + /> + })} + + + + +
+ +} + +/** + * Settings for email fields + * List to add and remove allowed domains + */ +const EmailFieldSettings = ({field, formValues, index}: Omit & { field: EmailField }) => { + return <> +
+ + { + (field.validator?.allowedDomains || []).length == 0 && ( + + Alle Email Adressen sind erlaubt + + ) + } + + {(field.validator?.allowedDomains ?? []).map((_, optionIndex: number) => { + return { + formValues.removeListItem(`fields.${index}.validator.allowedDomains`, optionIndex) + }} + > + + + + } + + {...formValues.getInputProps(`fields.${index}.validator.allowedDomains.${optionIndex}`)} + /> + })} + + + + +
+ +} + +/** + * 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 + {(provided) => ( +
+
+ + + + + } + variant="unstyled" + placeholder={"Name"} + required + disabled={field.meta?.required} + {...formValues.getInputProps(`fields.${index}.label`)} + /> + + + {humanReadableField(field.type)} + + + + { + showSettings ? : + } + +
+ + { + showSettings && + <> + + + {field.meta?.required && ( + + Dieses Feld wird vom System benötigt und kann nicht gelöscht werden. + + ) + } + + + + + + + { + // validation settings for text fields + (field.type === "text") && + + } + + { + // validation settings for number fields + (field.type === "number") && + + } + + { + // validation settings for date fields + (field.type === "date") && + + } + + + { + // settings for select fields + (field.type === "select") && + + } + + { + // settings for email fields + (field.type === "email") && + + } + + + + + { + field.type === "text" && <> + + + } + + { + field.type === "select" && <> + + + } + + + + + + } +
+ )} +
+} \ No newline at end of file diff --git a/src/components/formUtil/formBuilder/index.tsx b/src/components/formUtil/formBuilder/index.tsx new file mode 100644 index 0000000..5b1766a --- /dev/null +++ b/src/components/formUtil/formBuilder/index.tsx @@ -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 ( +
{ + onSubmit && onSubmit(values) + })} + > + + + destination?.index !== undefined && formValues.reorderListItem('fields', {from: source.index, to: destination.index}) + } + > + + {(provided) => ( +
+ {formValues.values.fields.map((field: FormSchemaField, index: number) => { + return + })} + {provided.placeholder} +
+ )} +
+
+ + + + + + + { + FieldTypes.map((fieldType) => { + return } + onClick={() => formValues.insertListItem("fields", createNewField(fieldType))} + key={fieldType} + > + {humanReadableField(fieldType)} + + }) + } + + + + { + withPreview && <> + +
+ + Die Vorschau zeigt nur die Struktur des Formulars an. Die Eingaben werden nicht + gespeichert. + + +
+
+ + + + } + + +
+
+ ) +} \ No newline at end of file diff --git a/src/components/formUtil/formBuilder/types.ts b/src/components/formUtil/formBuilder/types.ts new file mode 100644 index 0000000..73890e0 --- /dev/null +++ b/src/components/formUtil/formBuilder/types.ts @@ -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[], +} \ No newline at end of file diff --git a/src/components/formUtil/formTypeIcon.tsx b/src/components/formUtil/formTypeIcon.tsx new file mode 100644 index 0000000..21f5957 --- /dev/null +++ b/src/components/formUtil/formTypeIcon.tsx @@ -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 + case "email": + return + case "number": + return + case "date": + return + case "select": + return + case "checkbox": + return + } +} + +export default FormTypeIcon; diff --git a/src/components/formUtil/fromInput/index.tsx b/src/components/formUtil/fromInput/index.tsx new file mode 100644 index 0000000..88088e7 --- /dev/null +++ b/src/components/formUtil/fromInput/index.tsx @@ -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 + +} + +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