diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4058300 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +yarn-error.log +README.md +.gitignore +.git +.idea \ No newline at end of file diff --git a/.gitea/workflows/cicd.yml b/.gitea/workflows/cicd.yml new file mode 100644 index 0000000..b920c94 --- /dev/null +++ b/.gitea/workflows/cicd.yml @@ -0,0 +1,32 @@ +name: Build and Push Docker image + +on: + push: + branches: + - main + +jobs: + build-and-push: + runs-on: ubuntu-latest + + steps: + - name: Check out the repo + uses: actions/checkout@v2 + + - name: Log in to StuVe Gitea Container Registry + uses: docker/login-action@v1 + with: + registry: git.stuve.uni-ulm.de + username: ${{ secrets.USER_NAME }} + password: ${{ secrets.USER_PASSWORD }} + + - name: Build and push Docker image + uses: docker/build-push-action@v2 + with: + context: . + file: ./Dockerfile + push: true + tags: git.stuve.uni-ulm.de/stuve-it/stuve-it-frontend:latest + + # - name: Trigger webhook + # run: curl -X POST ${{ secrets.WEBHOOK_URL }} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4eedf66 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +# Stage 1: Build the React app using Vite +FROM node:18 AS build +WORKDIR /app + +# Copy package.json and package-lock.json to install dependencies +COPY package*.json ./ +RUN yarn install + +# Copy source code +COPY . . + +# Set environment variables +ENV NODE_ENV=production + +# Build the React app using Vite +RUN yarn run build + +# Stage 2: Create a lightweight production image +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html + +#copy public folder +COPY --from=build /app/public /usr/share/nginx/html + +#copy allowed mime types +COPY ./nginx/mime.types /etc/nginx/mime.types + +# Expose port 80 to access the app +EXPOSE 80 + +# Start Nginx server +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/README.md b/README.md index fc09198..98f7535 100644 --- a/README.md +++ b/README.md @@ -41,14 +41,19 @@ zu finden. ### Pocketbase API -Um die Kommunikation mit dem Backend zu erleichtern ist in der Datei "src/lib/pocketbase.ts" ein Pocketbase Client implementiert. +Um die Kommunikation mit dem Backend zu erleichtern ist in der Datei "s@/lib/pocketbase.ts" ein Pocketbase Client implementiert. Dieser Client ist ein React Hook und kann in jeder React Komponente verwendet werden. ```typescript -import {usePB} from "/lib/pocketbase"; +import {usePB} from@/lib/pocketbase"; const {pb} = usePB(); ``` -#### Pocketbase Modell Types \ No newline at end of file +#### Pocketbase Modell Types + + +## Todo + +- New Liste erstellen focus name field on +-Click \ No newline at end of file diff --git a/config.ts b/config.ts index 436cd2f..344b24c 100644 --- a/config.ts +++ b/config.ts @@ -1,51 +1,13 @@ /** * @description Global configuration file for the application */ -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://it.stuve.uni-ulm.de" -export const PB_STORAGE_KEY = "stuve-it-ldap-login" +export const PB_STORAGE_KEY = "stuve-it-login-record" // 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: "Seiten", - items: [ - { - title: "Home", - icon: IconHome, - description: "Home", - link: "/" - }, - { - title: "Events", - icon: IconInfoCircle, - description: "Administration für StuVe Events.", - link: "/events" - }, - { - title: "QR Code Generator", - icon: IconQrcode, - description: "Generiere einen QR Code", - link: "/util/qr" - } - ] - }, -] as { - section: string, - items: { - title: string, - icon: (props: TablerIconsProps) => ReactNode, - description: string, - link: string - }[] -}[] \ No newline at end of file +export const APP_VERSION = "0.5.0 (beta)" +export const APP_URL = "https://it.stuve.uni-ulm.de" \ No newline at end of file diff --git a/index.html b/index.html index 99f5d43..6a3e15d 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - + StuVe IT diff --git a/nginx/mime.types b/nginx/mime.types new file mode 100644 index 0000000..ead9ed4 --- /dev/null +++ b/nginx/mime.types @@ -0,0 +1,102 @@ +types { + # Manifest files + application/manifest+json webmanifest; + + # general + text/html html htm shtml; + text/css css; + text/xml xml; + image/gif gif; + image/jpeg jpeg jpg; + application/javascript js; + application/atom+xml atom; + application/rss+xml rss; + + text/mathml mml; + text/plain txt; + text/vnd.sun.j2me.app-descriptor jad; + text/vnd.wap.wml wml; + text/x-component htc; + + image/avif avif; + image/png png; + image/svg+xml svg svgz; + image/tiff tif tiff; + image/vnd.wap.wbmp wbmp; + image/webp webp; + image/x-icon ico; + image/x-jng jng; + image/x-ms-bmp bmp; + + font/woff woff; + font/woff2 woff2; + + application/java-archive jar war ear; + application/json json; + application/mac-binhex40 hqx; + application/msword doc; + application/pdf pdf; + application/postscript ps eps ai; + application/rtf rtf; + application/vnd.apple.mpegurl m3u8; + application/vnd.google-earth.kml+xml kml; + application/vnd.google-earth.kmz kmz; + application/vnd.ms-excel xls; + application/vnd.ms-fontobject eot; + application/vnd.ms-powerpoint ppt; + application/vnd.oasis.opendocument.graphics odg; + application/vnd.oasis.opendocument.presentation odp; + application/vnd.oasis.opendocument.spreadsheet ods; + application/vnd.oasis.opendocument.text odt; + application/vnd.openxmlformats-officedocument.presentationml.presentation + pptx; + application/vnd.openxmlformats-officedocument.spreadsheetml.sheet + xlsx; + application/vnd.openxmlformats-officedocument.wordprocessingml.document + docx; + application/vnd.wap.wmlc wmlc; + application/wasm wasm; + application/x-7z-compressed 7z; + application/x-cocoa cco; + application/x-java-archive-diff jardiff; + application/x-java-jnlp-file jnlp; + application/x-makeself run; + application/x-perl pl pm; + application/x-pilot prc pdb; + application/x-rar-compressed rar; + application/x-redhat-package-manager rpm; + application/x-sea sea; + application/x-shockwave-flash swf; + application/x-stuffit sit; + application/x-tcl tcl tk; + application/x-x509-ca-cert der pem crt; + application/x-xpinstall xpi; + application/xhtml+xml xhtml; + application/xspf+xml xspf; + application/zip zip; + + application/octet-stream bin exe dll; + application/octet-stream deb; + application/octet-stream dmg; + application/octet-stream iso img; + application/octet-stream msi msp msm; + + audio/midi mid midi kar; + audio/mpeg mp3; + audio/ogg ogg; + audio/x-m4a m4a; + audio/x-realaudio ra; + + video/3gpp 3gpp 3gp; + video/mp2t ts; + video/mp4 mp4; + video/mpeg mpeg mpg; + video/quicktime mov; + video/webm webm; + video/x-flv flv; + video/x-m4v m4v; + video/x-mng mng; + video/x-ms-asf asx asf; + video/x-ms-wmv wmv; + video/x-msvideo avi; +} \ No newline at end of file diff --git a/package.json b/package.json index 77f9a0a..42a5c51 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc && vite build", + "build": "export NODE_ENV=production && tsc && vite build", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview" }, @@ -18,10 +18,12 @@ "@mantine/dates": "^7.8.0", "@mantine/form": "^7.8.0", "@mantine/hooks": "^7.8.0", - "@mantine/notifications": "^7.8.0", + "@mantine/modals": "^7.9.0", + "@mantine/notifications": "^7.8.1", "@mantine/tiptap": "^7.8.0", - "@tabler/icons-react": "^2.39.0", + "@tabler/icons-react": "^3.2.0", "@tanstack/react-query": "^5.0.5", + "@tanstack/react-query-devtools": "^5.31.0", "@tiptap/extension-collaboration": "^2.3.0", "@tiptap/extension-collaboration-cursor": "^2.3.0", "@tiptap/extension-link": "^2.3.0", @@ -31,9 +33,11 @@ "@tiptap/react": "^2.3.0", "@tiptap/starter-kit": "^2.3.0", "@types/react-big-calendar": "^1.8.9", + "clsx": "^2.1.1", "dayjs": "^1.11.10", "jwt-decode": "^3.1.2", "ms": "^2.1.3", + "nanoid": "^5.0.7", "pocketbase": "^0.19.0", "react": "^18.2.0", "react-big-calendar": "^1.11.3", @@ -49,6 +53,7 @@ }, "devDependencies": { "@types/ms": "^0.7.33", + "@types/node": "^20.12.10", "@types/react": "^18.2.15", "@types/react-dom": "^18.2.7", "@types/sanitize-html": "^2.11.0", @@ -63,6 +68,7 @@ "postcss-simple-vars": "^7.0.1", "sass": "^1.75.0", "typescript": "^5.0.2", - "vite": "^4.4.5" + "vite": "^4.4.5", + "vite-plugin-svgr": "^4.2.0" } } diff --git a/src/Router.tsx b/src/Router.tsx index 0f71ef6..e991c22 100644 --- a/src/Router.tsx +++ b/src/Router.tsx @@ -1,9 +1,9 @@ import {createBrowserRouter, RouterProvider} from "react-router-dom"; import HomePage from "./pages/home/index.page.tsx"; import NotFound from "./pages/not-found/index.page.tsx"; -import Layout from "./components/layout"; +import Layout from "@/components/layout"; import QRCodeGenerator from "./pages/util/qr/index.page.tsx"; -import EventRouter from "./pages/events/router.tsx"; +import EventsRouter from "./pages/events/EventsRouter.tsx"; import PrivacyPolicy from "./pages/privacy-policy.page.tsx"; import TermsAndConditions from "./pages/terms-and-conditions.page.tsx"; @@ -30,7 +30,7 @@ const router = createBrowserRouter([ }, { path: "events/*", - element: , + element: , }, { path: "util", @@ -46,7 +46,7 @@ const router = createBrowserRouter([ element: } ] - }, + } ]) diff --git a/src/components/ConfirmModal.tsx b/src/components/ConfirmModal.tsx new file mode 100644 index 0000000..535027c --- /dev/null +++ b/src/components/ConfirmModal.tsx @@ -0,0 +1,81 @@ +import {Button, Group, Modal, ModalProps, Text} from "@mantine/core"; +import {useDisclosure} from "@mantine/hooks"; +import {IconAlertTriangle} from "@tabler/icons-react"; +import {useState} from "react"; + + +/** + * This hook provides a confirmation modal that can be used to confirm actions. + * It returns a function to toggle the modal and the modal component itself. + * @param title - the title of the modal (optional) + * @param description - the description of the modal + * @param onConfirm - the function to call when the user confirms + * @param onCancel - the function to call when the user cancels (optional) + * @param props - additional props for the modal + */ +export const useConfirmModal = ( + {title, description, onConfirm, onCancel, ...props}: { + title?: string, + description: string, + onConfirm: (t?: T) => void, + onCancel?: () => void + } & Omit +) => { + + const [showConfirmModal, handler] = useDisclosure(false) + + const [data, setData] = useState(undefined) + + const ConfirmModal = () => { + return <> + +
+ + {description} + + + + + +
+
+ + } + + return { + showConfirmModal, + toggleConfirmModal: (t?: T) => { + handler.toggle() + setData(t) + }, + ConfirmModal + } +} \ No newline at end of file diff --git a/src/components/PBAvatar.tsx b/src/components/PBAvatar.tsx index 75786ee..b491a51 100644 --- a/src/components/PBAvatar.tsx +++ b/src/components/PBAvatar.tsx @@ -1,11 +1,11 @@ import {Avatar, AvatarProps} from "@mantine/core"; -import {usePB} from "../lib/pocketbase.tsx"; +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 + HTMLDivElement, { name: string, img: string | null, model: RecordModel } & AvatarProps >(({model, img, name, ...props}, ref) => { const {pb} = usePB() const avatarSrc = img ? pb.files.getUrl(model, img) : null diff --git a/src/components/ShowDebug.tsx b/src/components/ShowDebug.tsx new file mode 100644 index 0000000..51aa83b --- /dev/null +++ b/src/components/ShowDebug.tsx @@ -0,0 +1,60 @@ +import {Alert, Tooltip} from "@mantine/core"; +import {IconBug} from "@tabler/icons-react"; +import {useLocalStorage} from "@mantine/hooks"; +import {ReactNode} from "react"; + + +/** + * Hook to manage the global state of all debug dialogs + * Stores the state in local storage + * @see ShowHelp + * @see useLocalStorage + * @returns {{showDebug: boolean, setShowDebug: (function(boolean): void), toggleShowDebug: (function(): void)}} + */ + +// eslint-disable-next-line react-refresh/only-export-components +export const useShowDebug = (): { + showDebug: boolean; + setShowDebug: ((arg0: boolean) => void); + toggleShowDebug: (() => void); +} => { + const [value, setValue] = useLocalStorage({ + key: 'stuve-it-show-debug', + defaultValue: 'false', + }) + + return { + showDebug: value === 'true', + setShowDebug: (value: boolean) => setValue(value ? 'true' : 'false'), + toggleShowDebug: () => setValue(value => value === 'true' ? 'false' : 'true'), + } +} + +/** + * Shows a help dialog if the user has not disabled it + * @see useShowHelp + * @param children - the content of the help dialog + */ +export default function ShowDebug({children}: { children: ReactNode }) { + + const {showDebug} = useShowDebug() + + if (!showDebug) { + return null + } + + return + + + }> + {children} + +} \ No newline at end of file diff --git a/src/components/ShowHelp.tsx b/src/components/ShowHelp.tsx new file mode 100644 index 0000000..ccb64ec --- /dev/null +++ b/src/components/ShowHelp.tsx @@ -0,0 +1,53 @@ +import {Alert, Tooltip} from "@mantine/core"; +import {IconQuestionMark} from "@tabler/icons-react"; +import {useLocalStorage} from "@mantine/hooks"; +import {ReactNode} from "react"; + + +/** + * Hook to manage the global state of all help dialogs + * Stores the state in local storage + * @see ShowHelp + * @see useLocalStorage + * @returns {{showHelp: boolean, setShowHelp: (function(boolean): void), toggleShowHelp: (function(): void)}} + */ +// eslint-disable-next-line react-refresh/only-export-components +export const useShowHelp = (): { showHelp: boolean; setShowHelp: ((arg0: boolean) => void); toggleShowHelp: (() => void); } => { + const [value, setValue] = useLocalStorage({ + key: 'stuve-it-show-help', + defaultValue: 'true', + }) + + return { + showHelp: value === 'true', + setShowHelp: (value: boolean) => setValue(value ? 'true' : 'false'), + toggleShowHelp: () => setValue(value => value === 'true' ? 'false' : 'true'), + } +} + +/** + * Shows a help dialog if the user has not disabled it + * @see useShowHelp + * @param children - the content of the help dialog + */ +export default function ShowHelp({children}: { children: ReactNode }) { + + const {showHelp} = useShowHelp() + + if (!showHelp) { + return null + } + + return + + + }> + {children} + +} \ No newline at end of file diff --git a/src/components/auth/LdapGroupInput.tsx b/src/components/auth/LdapGroupInput.tsx index c36bafa..86f92b6 100644 --- a/src/components/auth/LdapGroupInput.tsx +++ b/src/components/auth/LdapGroupInput.tsx @@ -1,6 +1,6 @@ import RecordSearchInput, {GenericRecordSearchInputProps} from "../input/RecordSearchInput.tsx"; -import {LdapGroupModel} from "../../models/AuthTypes.ts"; -import {usePB} from "../../lib/pocketbase.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"; diff --git a/src/components/auth/LdapGroupsDisplay.tsx b/src/components/auth/LdapGroupsDisplay.tsx index 0c3345b..a9359f3 100644 --- a/src/components/auth/LdapGroupsDisplay.tsx +++ b/src/components/auth/LdapGroupsDisplay.tsx @@ -1,12 +1,12 @@ import {List, Text, Tooltip} from "@mantine/core"; import {IconUsersGroup} from "@tabler/icons-react"; -import {LdapGroupModel} from "../../models/AuthTypes.ts"; +import {LdapGroupModel} from "@/models/AuthTypes.ts"; export default function LdapGroupsDisplay({groups}: { groups: LdapGroupModel[] }) { return < > } + icon={} > { groups.map((group) => ( diff --git a/src/components/auth/UserInput.tsx b/src/components/auth/LdapUserInput.tsx similarity index 85% rename from src/components/auth/UserInput.tsx rename to src/components/auth/LdapUserInput.tsx index e08ef59..711890b 100644 --- a/src/components/auth/UserInput.tsx +++ b/src/components/auth/LdapUserInput.tsx @@ -1,11 +1,11 @@ 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 {usePB} from "@/lib/pocketbase.tsx"; +import {LdapUserModel} from "@/models/AuthTypes.ts"; import RecordSearchInput, {GenericRecordSearchInputProps} from "../input/RecordSearchInput.tsx"; -export default function UserInput(props: GenericRecordSearchInputProps) { +export default function LdapUserInput(props: GenericRecordSearchInputProps) { const {pb} = usePB() @@ -13,8 +13,8 @@ export default function UserInput(props: GenericRecordSearchInputProps recordToString={(user) => ({displayName: `${user.givenName} ${user.sn}`})} - placeholder={props.placeholder || "Suche nach Personen..."} {...props} + placeholder={props.placeholder || "Suche nach Personen..."} leftSection={} recordSearchMutation={ useMutation({ diff --git a/src/components/auth/LdapUsersDisplay.tsx b/src/components/auth/LdapUsersDisplay.tsx new file mode 100644 index 0000000..69fc7dc --- /dev/null +++ b/src/components/auth/LdapUsersDisplay.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 LdapUsersDisplay({users}: { users: LdapUserModel[] }) { + + const {ldapUser} = usePB() + + return <> + }> + { + users.map((u) => ( + + {u.givenName} {u.sn} + + )) + } + + +} \ No newline at end of file diff --git a/src/components/auth/UsersDisplay.tsx b/src/components/auth/UsersDisplay.tsx deleted file mode 100644 index 8a421ec..0000000 --- a/src/components/auth/UsersDisplay.tsx +++ /dev/null @@ -1,21 +0,0 @@ -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/auth/modals/ChangeEmailModal.tsx b/src/components/auth/modals/ChangeEmailModal.tsx new file mode 100644 index 0000000..2de6495 --- /dev/null +++ b/src/components/auth/modals/ChangeEmailModal.tsx @@ -0,0 +1,176 @@ +import {useChangeEmail} from "@/components/auth/modals/hooks.ts"; +import {isEmail, useForm} from "@mantine/form"; +import {Button, Center, Group, Modal, PasswordInput, TextInput} from "@mantine/core"; +import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx"; +import {IconArrowForward, IconAt, IconKey, IconX} from "@tabler/icons-react"; +import {useMutation} from "@tanstack/react-query"; +import {showSuccessNotification} from "@/components/util.tsx"; +import {useSearchParams} from "react-router-dom"; + +import EmailSVG from "@/illustrations/email.svg?react" +import {useUser} from "@/lib/user.ts"; +import PromptLoginModal from "@/components/auth/modals/PromptLoginModal.tsx"; + +export const CHANGE_EMAIL_TOKEN_KEY = "changeEmailToken" + + +const RequestEmailChangeModal = ({open, onClose}: { + open: boolean, + onClose: () => void +}) => { + const {pb} = usePB() + + const formValues = useForm({ + initialValues: { + email: "", + }, + validate: { + email: isEmail("Ungültige E-Mail Adresse"), + } + }) + + const mutation = useMutation({ + mutationFn: async () => { + await pb.collection("guest_users").requestEmailChange(formValues.values.email) + }, + onSuccess: () => { + formValues.reset() + showSuccessNotification("Wir haben dir eine E-Mail an deine neue Adresse geschickt, bitte bestätige diese") + onClose() + } + }) + + return + +
mutation.mutate())}> + +
+ +
+ + + + } + required + {...formValues.getInputProps("email")} + /> + + + + + + + +
+} + +const ConfirmEmailChangeModal = ({open, onClose, token}: { + open: boolean, + onClose: () => void, + token: string +}) => { + const {pb, refreshUser} = usePB() + + const formValues = useForm({ + initialValues: { + password: "", + } + }) + + const mutation = useMutation({ + mutationFn: async () => { + await pb.collection("guest_users").confirmEmailChange( + token, + formValues.values.password, + ) + }, + onSuccess: () => { + formValues.reset() + showSuccessNotification("E-Mail erfolgreich geändert") + refreshUser() + onClose() + } + }) + + return + +
mutation.mutate())}> + +
+ +
+ + + + } + required + {...formValues.getInputProps("password")} + /> + + + + + + + +
+} + +export default function ChangeEmailModal() { + const {value, handler} = useChangeEmail() + + const user = useUser() + + const [searchParams] = useSearchParams() + + const token = searchParams.get(CHANGE_EMAIL_TOKEN_KEY) + + if (value && !user) { + return + } + + return <> + {token ? ( + + ) : ( + + )} + +} \ No newline at end of file diff --git a/src/components/auth/modals/EmailTokenVerification.tsx b/src/components/auth/modals/EmailTokenVerification.tsx new file mode 100644 index 0000000..c14827b --- /dev/null +++ b/src/components/auth/modals/EmailTokenVerification.tsx @@ -0,0 +1,88 @@ +import {useSearchParams} from "react-router-dom"; +import {useEffect, useState} from "react"; +import {useMutation} from "@tanstack/react-query"; +import {usePB} from "@/lib/pocketbase.tsx"; +import {showErrorNotification, showSuccessNotification} from "@/components/util.tsx"; +import {Alert, Button, Group, Modal, TextInput, Title} from "@mantine/core"; +import {IconAt} from "@tabler/icons-react"; +import {useLogin} from "@/components/auth/modals/hooks.ts"; +import {useUser} from "@/lib/user.ts"; + +export const EMAIL_TOKEN_KEY = "emailVerificationToken" + +/** + * This Component is used to verify auth tokens. + * + * The following tokens are supported: + * + * - Email verification + * - Password reset + * - Email change + * + * The token is extracted from the URL and the verification is done. + */ +export default function EmailTokenVerification() { + + const [searchParams] = useSearchParams() + + const {handler: loginHandler} = useLogin() + + const [email, setEmail] = useState("") + + const {pb, refreshUser} = usePB() + + const user = useUser() + + const verifyTokenMutation = useMutation({ + mutationFn: async (token: string) => { + await pb.collection("guest_users").confirmVerification(token) + }, + onSuccess: () => { + showSuccessNotification("E-Mail erfolgreich verifiziert") + refreshUser() + if (!user) { + loginHandler.open() + } + }, + onError: () => { + showErrorNotification("Fehler beim Verifizieren der E-Mail") + } + }) + + useEffect(() => { + const token = searchParams.get(EMAIL_TOKEN_KEY) + if (token !== null) { + verifyTokenMutation.mutate(token) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams]) + + return undefined} withCloseButton={false}> +
+ Token Verifizierung + + Deine E-Mail muss verifiziert werden. + Bitte schau in dein E-Mail Postfach und klicke auf den Button in der E-Mail. + + } + value={email} + onChange={(e) => setEmail(e.currentTarget.value)} + /> + + + +
+
+} \ No newline at end of file diff --git a/src/components/auth/modals/ForgotPasswordModal.tsx b/src/components/auth/modals/ForgotPasswordModal.tsx new file mode 100644 index 0000000..3075c6c --- /dev/null +++ b/src/components/auth/modals/ForgotPasswordModal.tsx @@ -0,0 +1,190 @@ +import {useForgotPassword, useLogin} from "@/components/auth/modals/hooks.ts"; +import {isEmail, useForm} from "@mantine/form"; +import {Button, Center, Collapse, Group, Modal, PasswordInput, TextInput} from "@mantine/core"; +import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx"; +import {IconAt, IconKey, IconPassword, IconX} from "@tabler/icons-react"; +import {useMutation} from "@tanstack/react-query"; +import {showSuccessNotification} from "@/components/util.tsx"; +import {useSearchParams} from "react-router-dom"; + +import PasswordSVG from "@/illustrations/boy-with-key.svg?react"; +import PasswordStrengthMeter, {getPasswordStrength} from "@/components/input/PasswordStrengthMeter.tsx"; + +export const PWD_RESET_TOKEN_KEY = "passwordResetToken" + + +const RequestResetPasswordModal = ({open, onClose}: { + open: boolean, + onClose: () => void +}) => { + const {pb} = usePB() + + const formValues = useForm({ + initialValues: { + email: "", + }, + validate: { + email: isEmail("Ungültige E-Mail Adresse"), + } + }) + + const requestResetPasswordMutation = useMutation({ + mutationFn: async () => { + await pb.collection("guest_users").requestPasswordReset(formValues.values.email) + }, + onSuccess: () => { + formValues.reset() + showSuccessNotification("Passwort zurücksetzen erfolgreich, bitte überprüfe deine E-Mails") + } + }) + + return + +
requestResetPasswordMutation.mutate())}> + +
+ +
+ + + + } + required + {...formValues.getInputProps("email")} + /> + + + + + + + +
+} + +const ResetPasswordModal = ({open, onClose, token}: { + open: boolean, + onClose: () => void, + token: string +}) => { + const {pb, userRecord} = usePB() + + const {handler: loginHandler} = useLogin() + + const formValues = useForm({ + initialValues: { + password: "", + passwordConfirm: "", + }, + validate: { + password: (val) => getPasswordStrength(val) !== 100 ? "Das Passwort ist zu schwach" : null, + passwordConfirm: (val, values) => val !== values.password ? "Die Passwörter stimmen nicht überein" : null + + } + }) + + const requestResetPasswordMutation = useMutation({ + mutationFn: async () => { + await pb.collection("guest_users").confirmPasswordReset( + token, + formValues.values.password, + formValues.values.passwordConfirm + ) + }, + onSuccess: () => { + formValues.reset() + showSuccessNotification("Passwort erfolgreich zurückgesetzt") + if (!userRecord) { + loginHandler.open() + } + } + }) + + return + +
requestResetPasswordMutation.mutate())}> + +
+ +
+ + + + } + required + {...formValues.getInputProps("password")} + /> + + 0} className={"stack"}> + } + required + {...formValues.getInputProps("passwordConfirm")} + /> + + + + + + + + + +
+} + +export default function ForgotPasswordModal() { + const {value, handler} = useForgotPassword() + + const [searchParams] = useSearchParams() + + const token = searchParams.get(PWD_RESET_TOKEN_KEY) + + return <> + {token ? ( + + ) : ( + + )} + +} \ No newline at end of file diff --git a/src/components/auth/modals/LoginModal.tsx b/src/components/auth/modals/LoginModal.tsx new file mode 100644 index 0000000..1c972e2 --- /dev/null +++ b/src/components/auth/modals/LoginModal.tsx @@ -0,0 +1,147 @@ +import {Link} from "react-router-dom"; +import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx"; +import {useForm} from "@mantine/form"; +import {useMutation} from "@tanstack/react-query"; +import { + Anchor, + Button, + Center, + Checkbox, + Divider, + Group, + Modal, + PasswordInput, + Radio, + Text, + TextInput, + Title +} from "@mantine/core"; +import LoginSVG from "@/illustrations/boy-with-key.svg?react" +import {useForgotPassword, useLogin, useRegister} from "@/components/auth/modals/hooks.ts"; +import {showSuccessNotification} from "@/components/util.tsx"; + + +export default function LoginModal() { + + const {value, handler} = useLogin() + + const {ldapLogin, guestLogin, userRecord} = usePB() + + const {handler: registerHandler} = useRegister() + + const {handler: forgorPasswordHandler} = useForgotPassword() + + const formValues = useForm({ + initialValues: { + username: "", + password: "", + authMethod: "ldap" as "ldap" | "guest", + privacy: false + } + }) + + const loginMutation = useMutation({ + mutationFn: async () => { + if (formValues.values.authMethod === "guest") { + await guestLogin(formValues.values.username, formValues.values.password) + } else { + await ldapLogin(formValues.values.username, formValues.values.password) + } + }, + onSuccess: () => { + handler.close() + formValues.reset() + showSuccessNotification("Erfolgreich eingeloggt") + } + }) + + return <> + +
loginMutation.mutate())}> + StuVe IT Login + +
+ +
+ + + + + + + + + + + + + + Ich akzeptiere die + Datenschutzerklärung + . + + } + {...formValues.getInputProps("privacy", {type: "checkbox"})} + /> + + + + + + + + + + + + + +
+ +} \ No newline at end of file diff --git a/src/components/auth/modals/PromptLoginModal.tsx b/src/components/auth/modals/PromptLoginModal.tsx new file mode 100644 index 0000000..c4a3ec6 --- /dev/null +++ b/src/components/auth/modals/PromptLoginModal.tsx @@ -0,0 +1,46 @@ +import {useDisclosure} from "@mantine/hooks"; +import {Button, Group, Modal} from "@mantine/core"; +import {useLogin} from "@/components/auth/modals/hooks.ts"; +import {IconLogin, IconX} from "@tabler/icons-react"; + +export default function PromptLoginModal({onAbort, description}: { + description?: string + onAbort: () => void +}) { + const [open, openHandler] = useDisclosure(true) + const {handler: loginHandler} = useLogin() + return +
+ + { + description ?? <> + Dieser Bereich ist nur für angemeldete Benutzer zugänglich. Bitte logge dich ein oder registriere dich. + + } + + + + + + +
+
+} \ No newline at end of file diff --git a/src/components/auth/modals/RegisterModal.tsx b/src/components/auth/modals/RegisterModal.tsx new file mode 100644 index 0000000..ef7ee41 --- /dev/null +++ b/src/components/auth/modals/RegisterModal.tsx @@ -0,0 +1,130 @@ +import {useRegister} from "@/components/auth/modals/hooks.ts"; +import {isEmail, useForm} from "@mantine/form"; +import {Alert, Button, Collapse, Group, Modal, PasswordInput, TextInput} from "@mantine/core"; +import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx"; +import {IconAt, IconKey, IconUser, IconUserPlus, IconX} from "@tabler/icons-react"; +import PasswordStrengthMeter, {getPasswordStrength} from "@/components/input/PasswordStrengthMeter.tsx"; +import {useMutation} from "@tanstack/react-query"; +import {showSuccessNotification} from "@/components/util.tsx"; + +export default function RegisterModal() { + const {value, handler} = useRegister() + + const {userRecord, pb} = usePB() + + const formValues = useForm({ + initialValues: { + username: "", + email: "", + password: "", + passwordConfirm: "", + sn: "", + givenName: "", + }, + validate: { + email: isEmail("Ungültige E-Mail Adresse"), + username: (val) => val.length < 3 ? "Der Anmeldename muss mindestens 3 Zeichen lang sein" : null, + password: (val) => getPasswordStrength(val) !== 100 ? "Das Passwort ist zu schwach" : null, + passwordConfirm: (val, values) => val !== values.password ? "Die Passwörter stimmen nicht überein" : null + } + }) + + const registerMutation = useMutation({ + mutationFn: async () => { + await pb.collection("guest_users").create({ + ...formValues.values, + }) + await pb.collection("guest_users").requestVerification(formValues.values.email) + }, + onSuccess: () => { + handler.close() + formValues.reset() + showSuccessNotification("Account erfolgreich angelegt") + showSuccessNotification("Bitte bestätige deine E-Mail Adresse") + } + }) + + return <> + + +
registerMutation.mutate())}> + + Hier kannst du einen Gast Account anlegen. Mit diesem Account kannst du dich zum Beispiel für + ein Event anmelden. +
+ Falls du einen StuVe IT Account hast, kannst du auch diesen Nutzen um dich anzumelden. +
+ + + + } + required + {...formValues.getInputProps("email")} + /> + + 0} className={"stack"}> + + } + required + {...formValues.getInputProps("username")} + /> + + 0} className={"stack"}> + + } + required + {...formValues.getInputProps("password")} + /> + + 0} className={"stack"}> + } + required + {...formValues.getInputProps("passwordConfirm")} + /> + + + + + + + + + + + +
+ + +} \ No newline at end of file diff --git a/src/components/auth/modals/UserMenuModal.tsx b/src/components/auth/modals/UserMenuModal.tsx new file mode 100644 index 0000000..2059da7 --- /dev/null +++ b/src/components/auth/modals/UserMenuModal.tsx @@ -0,0 +1,182 @@ +import {useChangeEmail, useForgotPassword, useUserMenu} from "@/components/auth/modals/hooks.ts"; +import {usePB} from "@/lib/pocketbase.tsx"; +import {useShowHelp} from "@/components/ShowHelp.tsx"; +import ShowDebug, {useShowDebug} from "@/components/ShowDebug.tsx"; +import {ActionIcon, Code, Divider, Group, Modal, Switch, Text, ThemeIcon, Title, Tooltip} from "@mantine/core"; +import classes from "@/components/layout/nav/index.module.css"; +import { + IconAt, + IconCalendar, + IconLogout, + IconMailCog, + IconPassword, + IconServer, + IconServerOff +} from "@tabler/icons-react"; +import LdapGroupsDisplay from "@/components/auth/LdapGroupsDisplay.tsx"; + +export default function UserMenuModal() { + const {value, handler} = useUserMenu() + + const {handler: passwordResetHandler} = useForgotPassword() + + const {handler: changeEmailHandler} = useChangeEmail() + + const {logout, apiIsHealthy, userRecord} = usePB() + + const {showHelp, toggleShowHelp} = useShowHelp() + + const {showDebug, toggleShowDebug} = useShowDebug() + + return <> + +
+ Hallo {userRecord?.username} + + + Datenbank ID: {userRecord?.id} + + {userRecord?.objectGUID && <> +
+ GUID: {userRecord?.objectGUID} + } +
+ +
+ + + + + + + {userRecord?.email} + +
+ +
+ + + + + + {userRecord?.accountExpires ? ( + + new Date(userRecord?.accountExpires).getTime() > Date.now() ? ( + "Account ist aktiv und läuft am " + new Date(userRecord?.accountExpires).toLocaleDateString() + " ab" + ) : ( + "Account ist abgelaufen" + ) + ) : ( + "Dein Account läuft nicht ab" + )} + +
+ +
+ {apiIsHealthy ? ( + + + + + ) : ( + + + + )} + + + {apiIsHealthy ? "Das Backend ist erreichbar" : "Das Backend ist nicht erreichbar"} + +
+ + {userRecord?.memberOf?.length && <> + Deine Gruppen + + } + + + + + Die folgenden Einstellungen werden lokal auf deinem Gerät gespeichert und nicht an den Server + übertragen. + + + + + + + Der Debug Modus ändert nicht das Verhalten der Anwendung, + sondern zeigt zusätzliche Informationen an. + + + + + + + { + handler.close() + changeEmailHandler.open() + }} + > + + + + + + { + handler.close() + passwordResetHandler.open() + }} + > + + + + + + { + logout() + handler.close() + }} + > + + + + +
+
+ +} \ No newline at end of file diff --git a/src/components/auth/modals/hooks.ts b/src/components/auth/modals/hooks.ts new file mode 100644 index 0000000..f86ca3a --- /dev/null +++ b/src/components/auth/modals/hooks.ts @@ -0,0 +1,32 @@ +import {useSearchParams} from "react-router-dom"; + +export const useSearchParamToggle = (key: string) => { + const [searchParams, setSearchParams] = useSearchParams() + + const value = searchParams.get(key) === "true" + + const open = () => setSearchParams(prev => ({...prev, [key]: "true"}), {replace: true}) + const close = () => setSearchParams(prev => { + prev.delete(key) + return prev + }, {replace: true}) + + return { + value: value, + handler: { + open, + close, + toggle: () => value ? close() : open() + } + } +} + +export const useLogin = () => useSearchParamToggle("login") + +export const useRegister = () => useSearchParamToggle("register") + +export const useUserMenu = () => useSearchParamToggle("userMenu") + +export const useForgotPassword = () => useSearchParamToggle("forgotPassword") + +export const useChangeEmail = () => useSearchParamToggle("changeEmail") \ No newline at end of file diff --git a/src/components/formUtil/formTypeIcon.tsx b/src/components/formUtil/FormTypeIcon.tsx similarity index 56% rename from src/components/formUtil/formTypeIcon.tsx rename to src/components/formUtil/FormTypeIcon.tsx index 21f5957..b36fecc 100644 --- a/src/components/formUtil/formTypeIcon.tsx +++ b/src/components/formUtil/FormTypeIcon.tsx @@ -1,15 +1,16 @@ -import {FieldType} from "./types.ts"; import { IconAt, - IconCalendar, + IconCalendarMonth, + IconCalendarTime, IconCheckbox, IconCursorText, IconHash, - IconList, - TablerIconsProps + IconList } from "@tabler/icons-react"; +import {FieldDataType} from "./formBuilder/types.ts"; +import {TablerIconProps} from "@/lib/helperTypes.ts"; -const FormTypeIcon = ({fieldType, ...props}: { fieldType: FieldType } & TablerIconsProps) => { +const FormTypeIcon = ({fieldType, ...props}: { fieldType: FieldDataType } & TablerIconProps) => { switch (fieldType) { case "text": return @@ -18,7 +19,9 @@ const FormTypeIcon = ({fieldType, ...props}: { fieldType: FieldType } & TablerIc case "number": return case "date": - return + return + case "date-range": + return case "select": return case "checkbox": diff --git a/src/components/formUtil/formBuilder/FormFieldsPreview.tsx b/src/components/formUtil/formBuilder/FormFieldsPreview.tsx new file mode 100644 index 0000000..6209936 --- /dev/null +++ b/src/components/formUtil/formBuilder/FormFieldsPreview.tsx @@ -0,0 +1,20 @@ +import {FormSchema} from "./types.ts"; +import {List, rem} from "@mantine/core"; +import FormTypeIcon from "../FormTypeIcon.tsx"; + +/** + * This component displays a preview of the form fields + * @param formSchema + */ +export default function FormFieldsPreview({formSchema}: { formSchema: FormSchema }) { + return + { + formSchema.fields.map((field, index) => { + return }> + {`${field.label}`} + + }) + } + +} \ No newline at end of file diff --git a/src/components/formUtil/formBuilder/editField.module.css b/src/components/formUtil/formBuilder/editField.module.css index 024a7b8..944e93f 100644 --- a/src/components/formUtil/formBuilder/editField.module.css +++ b/src/components/formUtil/formBuilder/editField.module.css @@ -7,11 +7,13 @@ padding: var(--padding); background-color: var(--mantine-color-body); - margin-bottom: var(--gap); - &[data-has-error=true]{ border-color: var(--mantine-color-error); } + + &:not(:last-of-type) { + margin-bottom: var(--gap); + } } .header { diff --git a/src/components/formUtil/formBuilder/editField.tsx b/src/components/formUtil/formBuilder/editField.tsx index c9670a7..172f28f 100644 --- a/src/components/formUtil/formBuilder/editField.tsx +++ b/src/components/formUtil/formBuilder/editField.tsx @@ -1,4 +1,4 @@ -import {EmailField, FormSchema, FormSchemaField, SelectField} from "./types.ts"; +import {EmailFieldSchema, FormSchema, FormSchemaField, SelectFieldSchema} from "./types.ts"; import {UseFormReturnType} from "@mantine/form"; import { ActionIcon, @@ -12,10 +12,13 @@ import { Stack, Text, TextInput, - ThemeIcon + ThemeIcon, + Tooltip } from "@mantine/core"; import { IconDragDrop, + IconGrid3x3, + IconInfoCircle, IconPlus, IconSettings, IconSettingsOff, @@ -23,7 +26,7 @@ import { IconTrashOff, IconX } from "@tabler/icons-react"; -import FormTypeIcon from "../formTypeIcon.tsx"; +import FormTypeIcon from "../FormTypeIcon.tsx"; import {DateTimePicker} from "@mantine/dates"; import {useDisclosure} from "@mantine/hooks"; @@ -110,7 +113,9 @@ const NumberFieldSettings = ({formValues, index}: Omit) * Settings for select fields * List to add and remove options */ -const SelectFieldSettings = ({field, formValues, index}: Omit & { field: SelectField }) => { +const SelectFieldSettings = ({field, formValues, index}: Omit & { + field: SelectFieldSchema +}) => { const error = formValues.errors?.[`fields.${index}.options`] @@ -165,7 +170,9 @@ const SelectFieldSettings = ({field, formValues, index}: Omit & { field: EmailField }) => { +const EmailFieldSettings = ({field, formValues, index}: Omit & { + field: EmailFieldSchema +}) => { return <>
@@ -241,17 +248,17 @@ export default function EditField({field, formValues, index}: EditFieldProps) { + } variant="unstyled" - placeholder={"Name"} + placeholder={"Name des Formularfeldes ..."} required disabled={field.meta?.required} {...formValues.getInputProps(`fields.${index}.label`)} /> - {humanReadableField(field.type)} + {humanReadableField(field.dataType)} {field.meta?.required && ( - - Dieses Feld wird vom System benötigt und kann nicht gelöscht werden. + }> + Dieses Feld kann nicht entfernt werden, da es als benötigt festgelegt wurde. - ) - } + )} + + {field.meta?.description && ( + }> + {field.meta.description} + + )} + + + }> + Feld ID: {field.id} + + } { // validation settings for number fields - (field.type === "number") && + (field.dataType === "number") && } { // validation settings for date fields - (field.type === "date") && + (field.dataType === "date") && } { // settings for select fields - (field.type === "select") && + (field.dataType === "select") && } { // settings for email fields - (field.type === "email") && + (field.dataType === "email") && } { - field.type === "text" && <> + field.dataType === "text" && <> + field.dataType === "select" && <> diff --git a/src/components/formUtil/formBuilder/index.tsx b/src/components/formUtil/formBuilder/index.tsx index 5b1766a..7db4e0d 100644 --- a/src/components/formUtil/formBuilder/index.tsx +++ b/src/components/formUtil/formBuilder/index.tsx @@ -1,31 +1,69 @@ -import {FieldTypes, FormSchema, FormSchemaField} from "./types.ts"; +import {FieldDataTypes, 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 {IconDatabaseOff, IconEye, IconSquarePlus2} 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"; +import {useEffect} from "react"; -export default function FormBuilder({initialValues, onSubmit, withPreview}: { - initialValues?: FormSchema, - onSubmit?: (values: FormSchema) => void, - withPreview?: boolean +/** + * FormBuilder + * This component is used to create a form schema by adding fields to it. + * The form schema can then be used to render a form with FormInput. + * @see FormInput + * @param defaultValue The default value of the form schema + * @param withPreview Whether to show a preview of the form + * @param onSubmit The function to call when the for schema is submitted + * @param reservedFieldLabels A list of reserved field labels that cannot be used + * @param additionalSchemaToPreview Additional schema to show in the preview, only used if withPreview is true + * @constructor + */ +export default function FormBuilder({ + defaultValue, + withPreview, + onSubmit, + reservedFieldLabels, + additionalSchemaToPreview + }: { + defaultValue: FormSchema, + onSubmit: (values: FormSchema) => void, + withPreview?: boolean, + additionalSchemaToPreview?: FormSchema, + reservedFieldLabels?: string[] }) { const [showPreview, setShowPreview] = useDisclosure(false) const formValues = useForm({ - mode: "uncontrolled", - initialValues: initialValues ?? { - fields: [] - } as FormSchema, + // mode: "uncontrolled", todo: fix this + initialValues: defaultValue, + validateInputOnChange: true, 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", + label: (value, values) => { + + // check if label is unique + if (values.fields.map(({label}) => label).filter(isNotEmpty).filter((label) => label === value).length > 1) { + return "Der Name muss eindeutig sein" + } + + // check if label is empty + if (value.length == 0) { + return "Der Name darf nicht leer sein" + } + + // check if label is in reservedFieldNames + if (reservedFieldLabels?.includes(value)) { + return "Der Name ist reserviert" + } + + return null + }, // check if all options are unique and not empty options: value => { @@ -49,16 +87,19 @@ export default function FormBuilder({initialValues, onSubmit, withPreview}: { } ) - return ( -
{ - onSubmit && onSubmit(values) - })} - > + useEffect(() => { + formValues.setValues(defaultValue) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [defaultValue]) + return ( + onSubmit(values))}> - destination?.index !== undefined && formValues.reorderListItem('fields', {from: source.index, to: destination.index}) + destination?.index !== undefined && formValues.reorderListItem('fields', { + from: source.index, + to: destination.index + }) } > @@ -75,13 +116,15 @@ export default function FormBuilder({initialValues, onSubmit, withPreview}: { - + { - FieldTypes.map((fieldType) => { + FieldDataTypes.map((fieldType) => { return } + leftSection={} onClick={() => formValues.insertListItem("fields", createNewField(fieldType))} key={fieldType} > @@ -96,27 +139,33 @@ export default function FormBuilder({initialValues, onSubmit, withPreview}: { withPreview && <>
- - Die Vorschau zeigt nur die Struktur des Formulars an. Die Eingaben werden nicht - gespeichert. + }> + Die Vorschau zeigt nur die Struktur des Formulars an. +
+ Die Eingaben werden nicht gespeichert.
- +
+ + } - - ) diff --git a/src/components/formUtil/formBuilder/types.ts b/src/components/formUtil/formBuilder/types.ts index 73890e0..b991bbb 100644 --- a/src/components/formUtil/formBuilder/types.ts +++ b/src/components/formUtil/formBuilder/types.ts @@ -1,4 +1,4 @@ -export type AbstractSchemaField = { +export type AbstractFieldSchema = { id: string label: string description: string | null @@ -6,56 +6,68 @@ export type AbstractSchemaField = { placeholder: string | null meta?: { required?: boolean + description?: string } } -export type TextField = { - type: "text" +export type TextFieldSchema = { + dataType: "text" multiline: boolean validator: { minLength: number | null maxLength: number | null regex: string } -} & AbstractSchemaField +} & AbstractFieldSchema -export type EmailField = { - type: "email", +export type EmailFieldSchema = { + dataType: "email", validator: { allowedDomains: string[], } -} & AbstractSchemaField +} & AbstractFieldSchema -export type NumberField = { - type: "number", +export type NumberFieldSchema = { + dataType: "number", validator: { min: number | null max: number | null } -} & AbstractSchemaField +} & AbstractFieldSchema -export type DateField = { - type: "date" +export type DateFieldSchema = { + dataType: "date" validator: { minDate: Date | null maxDate: Date | null } -} & AbstractSchemaField +} & AbstractFieldSchema -export type SelectField = { - type: "select", +export type DateRangeFieldSchema = { + dataType: "date-range" +} & AbstractFieldSchema + +export type SelectFieldSchema = { + dataType: "select", multiple: boolean options: string[] -} & AbstractSchemaField +} & AbstractFieldSchema -export type CheckboxField = { - type: "checkbox" -} & AbstractSchemaField +export type CheckboxFieldSchema = { + dataType: "checkbox" +} & AbstractFieldSchema -export type FormSchemaField = TextField | EmailField | NumberField | DateField | SelectField | CheckboxField +export type FormSchemaField = + TextFieldSchema + | EmailFieldSchema + | NumberFieldSchema + | DateFieldSchema + | SelectFieldSchema + | CheckboxFieldSchema + | DateRangeFieldSchema -export type FieldType = "text" | "number" | "date" | "select" | "checkbox" | "email" -export const FieldTypes: FieldType[] = ["text", "email", "number", "date", "select", "checkbox"] +export type FieldDataType = FormSchemaField["dataType"] +export const FieldDataTypes: FieldDataType[] = ["text", "email", "number", "date", "date-range", "select", "checkbox"] export type FormSchema = { fields: FormSchemaField[], diff --git a/src/components/formUtil/formTable/RenderCell.tsx b/src/components/formUtil/formTable/RenderCell.tsx new file mode 100644 index 0000000..995666c --- /dev/null +++ b/src/components/formUtil/formTable/RenderCell.tsx @@ -0,0 +1,185 @@ +import {FieldEntry} from "../fromInput/types.ts"; +import {ReactNode} from "react"; +import {Anchor, Badge, Code, Group, List, Spoiler, ThemeIcon, Tooltip} from "@mantine/core"; +import { + IconCalendar, + IconCheck, + IconCheckbox, + IconCircleOff, + IconClock, + IconEye, + IconEyeOff, + IconX +} from "@tabler/icons-react"; +import {areDatesSame, formatDuration, pprintDateTime} from "@/lib/datetime.ts"; +import dayjs from "dayjs"; + +const EmptyCell = () => { + return <> + + } + variant="light" color="gray" + > + N/A + + + +} + +const DateTimeCell = ({date}: { date: Date | string }) => { + return <> + {pprintDateTime(date)} + +} + +const DateRangeCell = ({dateRange}: { dateRange: [Date | null, Date | null] }) => { + + const [start, end] = dateRange + + if (start === null || end === null) { + return + } + + const duration = formatDuration(start, end) + + // case for same date + if (areDatesSame(start, end)) { + return + + + + {dayjs(start).format("HH:mm")} + {"-"} + {dayjs(end).format("HH:mm")} + + + + {dayjs(start).format("DD.MM.YY")} + + + + } + + // case both dates start at 00:00:00 + if (start.getHours() === 0 && start.getMinutes() === 0 && start.getSeconds() === 0 && + end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0) { + return + + + + {dayjs(start).format("DD.MM.YY")} + + {"-"} + + + {dayjs(end).format("DD.MM.YY")} + + + + } + + // case different dates and times + return + + + + {dayjs(start).format("HH:mm")} + + {dayjs(start).format("DD.MM.YY")} + + {"bis"} + + + {dayjs(end).format("HH:mm")} + + {dayjs(end).format("DD.MM.YY")} + + + +} + +const NumberCell = ({value}: { value: number }) => { + return {value} +} + +const MailCell = ({value}: { value: string }) => { + return {value} +} + +const TextCell = ({value}: { value: string }) => { + return <> + + + + } + hideLabel={ + + + + } + > + {value} + + +} + +const BooleanCell = ({value}: { value: boolean }) => { + return ( + : } + color={value ? "green" : "red"} + > + {value ? "Ja" : "Nein"} + + ) +} + +const SelectCell = ({value}: { value: string | string[] }) => { + return <> + }> + { + (Array.isArray(value) ? value : [value]).map((item, index) => ( + + {item} + + )) + } + + +} + +export default function RenderCell({entry}: { entry?: FieldEntry }): ReactNode { + + if (!entry) { + return + } + + const {value, dataType} = entry + + if (value === undefined || + value === null || + value === '') { + return + } + + switch (dataType) { + case "date": + return + case "date-range": + return + case "number": + return + case "email": + return + case "text": + return + case "checkbox": + return + case "select": + return + } +} diff --git a/src/components/formUtil/formTable/index.tsx b/src/components/formUtil/formTable/index.tsx new file mode 100644 index 0000000..59c97fe --- /dev/null +++ b/src/components/formUtil/formTable/index.tsx @@ -0,0 +1,35 @@ +import {FieldEntries, FieldEntry} from "../fromInput/types.ts" +import {objectMap} from "@/lib/util.ts"; +import {FormSchemaField} from "../formBuilder/types.ts"; + +export const renderEntries = (entries: FieldEntries, fields: FormSchemaField[]): { + label: string, + value: FieldEntry, +}[] => fields.map(field => ({ + label: field.label || field.id, + value: entries[field.id] +})) + + +export const transformData = (d: FieldEntries) => objectMap(d, (_, entry) => { + switch (entry.dataType) { + case "date": + return { + dataType: "date", + value: entry.value === null ? null : new Date(entry.value) + } as FieldEntry + case "date-range": + return { + dataType: "date-range", + value: entry.value.some(v => v === null) ? [null, null] : [new Date(entry.value[0]!), new Date(entry.value[1]!)] + } as FieldEntry + case 'number': + return { + dataType: 'number', + value: entry.value === null ? null : Number(entry.value) + } as FieldEntry + default: + return entry + } +}) + diff --git a/src/components/formUtil/formTable/sorter.ts b/src/components/formUtil/formTable/sorter.ts new file mode 100644 index 0000000..f7fcd92 --- /dev/null +++ b/src/components/formUtil/formTable/sorter.ts @@ -0,0 +1,32 @@ +import {FieldDataType} from "../formBuilder/types.ts"; +import {FieldEntryValue} from "../fromInput/types.ts"; +import dayjs from "dayjs"; + +/** + * This function sorts the cells based on their type and value + * @param dataType + * @param v1 + * @param v2 + */ +export const sortCell = (dataType: FieldDataType, v1: FieldEntryValue, v2: FieldEntryValue): number => { + if (typeof v1 !== typeof v2) return 0 + if (v1 === undefined || v1 === null || v1 === '') return -1 + if (v2 === undefined || v2 === null || v2 === '') return 1 + switch (dataType) { + case "text": + case "email": + return (v1 as string).localeCompare(v2 as string) + case "number": + return (v1 as number) - (v2 as number) + case "date": + return dayjs(v1 as string).diff(v2 as string) + case "date-range": + return dayjs((v1 as string [])[0]).diff((v2 as string[])[0]) + case "checkbox": + return v1 === v2 ? 0 : v1 ? 1 : -1 + case "select": + return JSON.stringify(v1).localeCompare(JSON.stringify(v2)) + default: + return 0 + } +} \ No newline at end of file diff --git a/src/components/formUtil/fromInput/formFieldComponents.tsx b/src/components/formUtil/fromInput/formFieldComponents.tsx new file mode 100644 index 0000000..49f8676 --- /dev/null +++ b/src/components/formUtil/fromInput/formFieldComponents.tsx @@ -0,0 +1,118 @@ +import {DatePickerInput, DatePickerInputProps, DateTimePicker, DateTimePickerProps} from "@mantine/dates"; +import { + MultiSelect, + MultiSelectProps, + NumberInput, + NumberInputProps, + Select, + SelectProps, + Textarea, + TextareaProps, + TextInput, + TextInputProps +} from "@mantine/core"; +import {IconAt} from "@tabler/icons-react"; +import { + CheckboxFieldSchema, + DateFieldSchema, + DateRangeFieldSchema, + EmailFieldSchema, + FormSchemaField, + NumberFieldSchema, + SelectFieldSchema, + TextFieldSchema +} from "../formBuilder/types.ts"; +import {CheckboxCard, CheckboxCardProps} from "@/components/input/CheckboxCard"; + +/** + * This function unpacks a field into a set of props that can be used in the input components + * @param field + */ +const unpackField = (field: FormSchemaField) => { + return { + label: field.label || undefined, + placeholder: field.placeholder || undefined, + description: field.description || undefined, + required: field.required || false, + } +} + +export const FormTextField = ({field, ...props}: { field: TextFieldSchema } & TextInputProps) => { + if (field.multiline) { + console.error("for multiline text fields use FormTextareaField instead of FormTextField") + return null + } + + return + +} +export const FormTextareaField = ({field, ...props}: { field: TextFieldSchema } & TextareaProps) => { + if (!field.multiline) { + console.error("for single line text fields use FormTextField instead of FormTextareaField") + return null + } + return