From 7cef873acd5c649da89e3d477af358512575602a Mon Sep 17 00:00:00 2001 From: valentinkolb Date: Mon, 10 Jun 2024 19:49:42 +0200 Subject: [PATCH] feat(statusEditor): added StatusEditor.tsx with mobile first design --- index.html | 4 +- package.json | 3 +- src/components/users/modals/UserMenuModal.tsx | 176 +++++++++++++----- src/lib/util.ts | 47 ++++- src/pages/events/EventsRouter.tsx | 2 + .../EditEntryStatus/EditStatus.tsx | 48 +++++ .../EditEntryStatus/ShowStatus.tsx | 37 ++++ .../StatusEditor/EditEntryStatus/index.tsx | 84 +++++++++ src/pages/events/StatusEditor/EntrySearch.tsx | 45 +++++ .../StatusEditor/EntrySearchModal.module.css | 34 ++++ .../events/StatusEditor/EntrySearchModal.tsx | 141 ++++++++++++++ src/pages/events/StatusEditor/QrCodeModal.tsx | 170 +++++++++++++++++ .../events/StatusEditor/StatusEditor.tsx | 58 ++++++ .../events/e/:eventId/EditEventRouter.tsx | 1 - .../:eventId/EventLists/EventListsRouter.tsx | 15 +- .../e/:eventId/EventLists/Search/index.tsx | 16 +- src/pages/events/entries/UserEntryRow.tsx | 55 +++++- src/pages/home/index.page.tsx | 3 +- src/style/global.css | 2 + yarn.lock | 45 +++++ 20 files changed, 916 insertions(+), 70 deletions(-) create mode 100644 src/pages/events/StatusEditor/EditEntryStatus/EditStatus.tsx create mode 100644 src/pages/events/StatusEditor/EditEntryStatus/ShowStatus.tsx create mode 100644 src/pages/events/StatusEditor/EditEntryStatus/index.tsx create mode 100644 src/pages/events/StatusEditor/EntrySearch.tsx create mode 100644 src/pages/events/StatusEditor/EntrySearchModal.module.css create mode 100644 src/pages/events/StatusEditor/EntrySearchModal.tsx create mode 100644 src/pages/events/StatusEditor/QrCodeModal.tsx create mode 100644 src/pages/events/StatusEditor/StatusEditor.tsx diff --git a/index.html b/index.html index 6a3e15d..2707e08 100644 --- a/index.html +++ b/index.html @@ -2,9 +2,11 @@ - + + StuVe IT +
diff --git a/package.json b/package.json index 9f79f86..e1ffa79 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", + "dev": "vite --host", "build": "export NODE_ENV=production && tsc && vite build", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview" @@ -35,6 +35,7 @@ "@tiptap/starter-kit": "^2.3.0", "@tiptap/suggestion": "^2.4.0", "@types/react-big-calendar": "^1.8.9", + "@yudiel/react-qr-scanner": "^2.0.2", "clsx": "^2.1.1", "dayjs": "^1.11.10", "jwt-decode": "^3.1.2", diff --git a/src/components/users/modals/UserMenuModal.tsx b/src/components/users/modals/UserMenuModal.tsx index 37dc218..5c14c75 100644 --- a/src/components/users/modals/UserMenuModal.tsx +++ b/src/components/users/modals/UserMenuModal.tsx @@ -1,22 +1,39 @@ import {useChangeEmail, useForgotPassword, useUserMenu} from "@/components/users/modals/hooks.ts"; -import {usePB} from "@/lib/pocketbase.tsx"; +import {PocketBaseErrorAlert, 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 { + ActionIcon, + Alert, + Code, + Divider, + Group, + Modal, + Switch, + Text, + TextInput, + ThemeIcon, + Title, + Tooltip, +} from "@mantine/core"; import classes from "@/components/layout/nav/index.module.css"; import { IconAt, IconCalendar, + IconDeviceFloppy, IconLogout, IconMailCog, IconPassword, IconRefresh, IconServer, - IconServerOff, - IconUser + IconServerOff } from "@tabler/icons-react"; import LdapGroupsDisplay from "@/components/users/LdapGroupsDisplay.tsx"; import ColorSchemeSwitch from "@/components/input/ColorSchemeSwitch.tsx"; +import {getUserName} from "@/components/users/modals/util.tsx"; +import {isNotEmpty, useForm} from "@mantine/form"; +import {useMutation} from "@tanstack/react-query"; +import {showSuccessNotification} from "@/components/util.tsx"; export default function UserMenuModal() { const {value, handler} = useUserMenu() @@ -25,22 +42,58 @@ export default function UserMenuModal() { const {handler: changeEmailHandler} = useChangeEmail() - const {logout, apiIsHealthy, user, refreshUser} = usePB() + const {logout, apiIsHealthy, pb, user, refreshUser} = usePB() const {showHelp, toggleShowHelp} = useShowHelp() const {showDebug, toggleShowDebug} = useShowDebug() + const formValues = useForm({ + initialValues: { + sn: user?.sn ?? "", + givenName: user?.givenName ?? "", + }, + validate: { + sn: isNotEmpty('Bitte gebe deinen Nachnamen ein'), + givenName: isNotEmpty('Bitte gebe deinen Vornamen ein'), + } + }) + + const mutation = useMutation({ + mutationFn: async () => { + if (formValues.validate().hasErrors || !user) { + throw new Error("Validation failed") + } + + return await pb.collection("users").update(user!.id, { + ...formValues.values + }) + }, + onSuccess: () => { + showSuccessNotification("Dein Name wurde erfolgreich gespeichert") + refreshUser() + formValues.reset() + } + }) + + // modal must be open if the user has no givenName or sn + const userHasNoName = (!user?.sn || !user?.givenName) && !!user + return <> - +
- Hallo {user?.username} + + {userHasNoName && ( + + Bitte vervollständige deinen Account, um alle Funktionen nutzen zu können. + + )} + + Hallo {getUserName(user)} - Datenbank ID: {user?.id} - + User ID: {user?.id}
- REALM: {user?.REALM} {user?.objectGUID && <> @@ -50,43 +103,25 @@ export default function UserMenuModal() {
- - - - - - - {user?.REALM === "LDAP" ? "StuVe IT Account" : "Gast Account"} - -
- -
- + - - + + {user?.email}
- + - + {user?.accountExpires ? ( new Date(user?.accountExpires).getTime() > Date.now() ? ( @@ -105,7 +140,6 @@ export default function UserMenuModal() { @@ -113,13 +147,12 @@ export default function UserMenuModal() { )} - + {apiIsHealthy ? "Das Backend ist erreichbar" : "Das Backend ist nicht erreichbar"}
@@ -147,6 +180,7 @@ export default function UserMenuModal() { @@ -159,9 +193,47 @@ export default function UserMenuModal() { label={"Account"} /> - + {userHasNoName && + Bitte vervollständige deinen Account, um alle Funktionen nutzen zu können. } + { + user?.REALM === "GUEST" && <> + + + + + + + } + + {user?.REALM === "GUEST" && <> + + { + mutation.mutate() + }} + color={"green"} + disabled={!formValues.isTouched()} + > + + + + } - - { - refreshUser() - }} - > - - - + {showDebug && + + { + refreshUser() + }} + > + + + + } { return `${PB_BASE_URL}/api/qr/v1?${createQueryParams(options)}` } +/** + * The settings for the QR code. + * @typedef {Object} QRCodeScannerSettings + * @property {boolean} torch - If true, the torch is on. Otherwise, it's off. + * @property {boolean} sound - If true, the sound is on. Otherwise, it's off. + */ +export type QRCodeScannerSettings = { + torch: boolean, + sound: boolean, +} + +/** + * A hook that provides a way to get and set QR code settings. + * The settings are stored in local storage under the key 'stuve-it-qr-code-settings'. + * The default settings are { torch: false, sound: true }. + * @returns {Array} A tuple where the first element is the current settings and the second element is a function to update the settings. + * The update function takes a partial settings object and merges it with the current settings. + * @example + * const [settings, setSettings] = useQRCodeSettings(); + * setSettings({ torch: true }); // turns the torch on + */ +export const useQRCodeScannerSettings = (): [QRCodeScannerSettings, (s: Partial) => void] => { + const [_value, _setValue] = useLocalStorage({ + key: 'stuve-it-qr-code-settings', + defaultValue: JSON.stringify({ + torch: false, + sound: true, + } as QRCodeScannerSettings), + }) + + const value = JSON.parse(_value) + const setValue = (settings: Partial) => _setValue(JSON.stringify({...value, ...settings})) + + return [value, setValue] +} + export const flattenReducer = (acc: T[], val: T | T[]): T[] => { if (Array.isArray(val)) { return [...acc, ...val] diff --git a/src/pages/events/EventsRouter.tsx b/src/pages/events/EventsRouter.tsx index acbf25c..4ff6c65 100644 --- a/src/pages/events/EventsRouter.tsx +++ b/src/pages/events/EventsRouter.tsx @@ -5,6 +5,7 @@ import EventView from "./s/EventView.tsx"; import EventNavigate from "@/pages/events/EventNavigate.tsx"; import EditEventRouter from "@/pages/events/e/:eventId/EditEventRouter.tsx"; import UserEntries from "@/pages/events/entries/UserEntries.tsx"; +import StatusEditor from "@/pages/events/StatusEditor/StatusEditor.tsx"; export default function EventsRouter() { @@ -14,6 +15,7 @@ export default function EventsRouter() { }/> }/> }/> + }/> }/> }/> diff --git a/src/pages/events/StatusEditor/EditEntryStatus/EditStatus.tsx b/src/pages/events/StatusEditor/EditEntryStatus/EditStatus.tsx new file mode 100644 index 0000000..cf61e01 --- /dev/null +++ b/src/pages/events/StatusEditor/EditEntryStatus/EditStatus.tsx @@ -0,0 +1,48 @@ +import {EventListSlotEntriesWithUserModel} from "@/models/EventTypes.ts"; +import {useMutation} from "@tanstack/react-query"; +import {FieldEntries} from "@/components/formUtil/FromInput/types.ts"; +import {showSuccessNotification} from "@/components/util.tsx"; +import {getUserName} from "@/components/users/modals/util.tsx"; +import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx"; +import FormInput from "@/components/formUtil/FromInput"; +import {getListSchemas} from "@/pages/events/util.ts"; +import {useNavigate, useParams} from "react-router-dom"; + +export default function EditStatus({entry, refetch}: { + entry: EventListSlotEntriesWithUserModel + refetch: () => void +}) { + const {pb} = usePB() + const {eventId} = useParams() as { entryId: string, eventId: string } + const navigate = useNavigate() + + const mutation = useMutation({ + mutationFn: async (values: FieldEntries) => { + return await pb.collection("eventListSlotEntries").update(entry!.id, { + entryStatusData: values + }) + }, + onSuccess: () => { + showSuccessNotification(`Status von ${getUserName(entry!.expand?.user)} erfolgreich aktualisiert`) + navigate(`/events/e/${eventId}/lists/status/${entry.id}/s`, {replace: true}) + refetch() + } + }) + + const {statusSchema} = getListSchemas(entry) + + return <> +
+ +
+ +
+ navigate(`/events/e/${eventId}/lists/status`, {replace: true})} + initialData={entry.entryStatusData ?? undefined} + /> +
+ +} \ No newline at end of file diff --git a/src/pages/events/StatusEditor/EditEntryStatus/ShowStatus.tsx b/src/pages/events/StatusEditor/EditEntryStatus/ShowStatus.tsx new file mode 100644 index 0000000..5d1f59e --- /dev/null +++ b/src/pages/events/StatusEditor/EditEntryStatus/ShowStatus.tsx @@ -0,0 +1,37 @@ +import {EventListSlotEntriesWithUserModel} from "@/models/EventTypes.ts"; +import EntryStatusSpoiler from "@/pages/events/e/:eventId/EventLists/EventListComponents/EntryStatusSpoiler.tsx"; +import {Button, Group} from "@mantine/core"; +import {IconCheck, IconCheckupList} from "@tabler/icons-react"; +import {Link} from "react-router-dom"; + +export default function ShowStatus({entry}: { + entry: EventListSlotEntriesWithUserModel +}) { + return <> +
+ +
+ +
+ + + + + + + +
+ +} \ No newline at end of file diff --git a/src/pages/events/StatusEditor/EditEntryStatus/index.tsx b/src/pages/events/StatusEditor/EditEntryStatus/index.tsx new file mode 100644 index 0000000..e5e95aa --- /dev/null +++ b/src/pages/events/StatusEditor/EditEntryStatus/index.tsx @@ -0,0 +1,84 @@ +import {Navigate, Route, Routes, useParams} from "react-router-dom"; +import {useQuery} from "@tanstack/react-query"; +import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx"; +import {Button, Center, Collapse, Divider, Group, Loader, Text, Title} from "@mantine/core"; +import {getUserName} from "@/components/users/modals/util.tsx"; +import {RenderDateRange} from "@/pages/events/e/:eventId/EventLists/EventListComponents/RenderDateRange.tsx"; +import EditStatus from "@/pages/events/StatusEditor/EditEntryStatus/EditStatus.tsx"; +import NotFound from "@/pages/not-found/index.page.tsx"; +import {humanDeltaFromNow} from "@/lib/datetime.ts"; +import InnerHtml from "@/components/InnerHtml"; +import {useDisclosure} from "@mantine/hooks"; +import {IconEye, IconEyeOff} from "@tabler/icons-react"; +import ShowStatus from "@/pages/events/StatusEditor/EditEntryStatus/ShowStatus.tsx"; + +export default function Index() { + const {entryId} = useParams() as { entryId: string, eventId: string } + const {pb} = usePB() + + const [showDescription, {toggle}] = useDisclosure(false); + + const entryQuery = useQuery({ + queryKey: ["entry", entryId], + queryFn: async () => (await pb.collection("eventListSlotEntriesWithUser").getOne(entryId, { + expand: "user" + })) + }) + + const entry = entryQuery.data + + if (entryQuery.isPending) { + return
+ +
+ } + + if (entryQuery.isError || !entry) { + return + } + + return <> +
+ + {getUserName(entry.expand?.user)} + + + + + {entry.listName} + + + ({humanDeltaFromNow(entry.slotStartDate, entry.slotEndDate).message}) + + + { + (entry.listDescription || entry.slotDescription) && <> + + + + + + <> + {entry.listDescription && } + {entry.slotDescription && } + + + + } +
+ + + }/> + }/> + }/> + }/> + + +} \ No newline at end of file diff --git a/src/pages/events/StatusEditor/EntrySearch.tsx b/src/pages/events/StatusEditor/EntrySearch.tsx new file mode 100644 index 0000000..96ca023 --- /dev/null +++ b/src/pages/events/StatusEditor/EntrySearch.tsx @@ -0,0 +1,45 @@ +import QRCodeModal from "@/pages/events/StatusEditor/QrCodeModal.tsx"; +import EntrySearchModal from "@/pages/events/StatusEditor/EntrySearchModal.tsx"; +import {ActionIcon, Center, Group, Text} from "@mantine/core"; +import {IconQrcode, IconUserSearch} from "@tabler/icons-react"; +import {useDisclosure} from "@mantine/hooks"; +import SearchSVG from "@/illustrations/search.svg?react" + + +export default function EntrySearch() { + const [showQrCodeModal, showQrCodeModalHandler] = useDisclosure(false) + const [showEntrySearchModal, showEntrySearchModalHandler] = useDisclosure(false) + + return <> + + + +
+ +
+ +
+ + + + + + + oder + + + + + + +
+ +} \ No newline at end of file diff --git a/src/pages/events/StatusEditor/EntrySearchModal.module.css b/src/pages/events/StatusEditor/EntrySearchModal.module.css new file mode 100644 index 0000000..a8cf4dd --- /dev/null +++ b/src/pages/events/StatusEditor/EntrySearchModal.module.css @@ -0,0 +1,34 @@ +.container { + transition: height 200ms ease, width 200ms ease, padding 200ms ease, border 200ms ease, border-radius 200ms ease, background-color 200ms ease, max-width 200ms ease; + max-height: 100%; + display: flex; + flex-direction: column; +} + +.content { + overflow: hidden; + position: relative; + display: flex; +} + +.body { + padding: 0; + max-height: 100%; + overflow: hidden; + flex: 1; +} + + +.entry { + padding: var(--mantine-spacing-xs); + display: flex; + flex-direction: row; + align-items: center; +} + +.entryDetails { + display: flex; + flex-direction: column; + gap: calc(var(--mantine-spacing-xs) / 2); + flex: 1; +} \ No newline at end of file diff --git a/src/pages/events/StatusEditor/EntrySearchModal.tsx b/src/pages/events/StatusEditor/EntrySearchModal.tsx new file mode 100644 index 0000000..ed1b696 --- /dev/null +++ b/src/pages/events/StatusEditor/EntrySearchModal.tsx @@ -0,0 +1,141 @@ +import {ActionIcon, Kbd, Loader, Modal, Text, TextInput} from "@mantine/core"; +import {getHotkeyHandler, useDebouncedValue} from "@mantine/hooks"; +import {useQuery} from "@tanstack/react-query"; +import {IconUserSearch, IconX} from "@tabler/icons-react"; +import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx"; +import {Link, useNavigate, useParams} from "react-router-dom"; +import {useState} from "react"; +import {getUserName} from "@/components/users/modals/util.tsx"; + +import classes from "./EntrySearchModal.module.css"; +import {humanDeltaFromNow, pprintDateRange} from "@/lib/datetime.ts"; + +export default function EntrySearchModal({opened, onClose}: { + opened: boolean, + onClose: () => void, +}) { + + const {pb} = usePB() + + const {eventId} = useParams() as { eventId: string } + const navigate = useNavigate() + + const [searchValue, setSearchValue] = useState(''); + const [debouncedSearchValue] = useDebouncedValue(searchValue, 300); + + const userQuery = useQuery({ + queryKey: ["eventListSlotEntriesWithUser", debouncedSearchValue], + queryFn: async () => { + const filter = [] as string[] + + const usernameQuery = debouncedSearchValue.trim() + const givenName = debouncedSearchValue.split(" ")[1]?.trim() ?? null + const surname = debouncedSearchValue.split(" ")[0]?.trim() ?? givenName + + usernameQuery && filter.push(`user.username~'${usernameQuery}'`) + givenName && filter.push(`user.givenName~'${givenName}'`) + surname && filter.push(`user.sn~'${surname}'`) + + if (filter.length === 0) { + return null + } + + return await pb.collection("eventListSlotEntriesWithUser").getList(1, 40, { + filter: `(${filter.join("||")}) && event~'${eventId}'`, + expand: "user", + sort: "-slotStartDate" + }) + }, + retry: false, + enabled: opened + }) + + return +
+ } + rightSection={userQuery.isLoading ? : searchValue.length !== 0 ? ( + setSearchValue('')} + aria-label={"Clear"} + > + + + ) : undefined} + placeholder={`Nach Anmeldungen suchen...`} + value={searchValue} + onChange={(e) => setSearchValue(e.target.value)} + onKeyDown={getHotkeyHandler( + (userQuery.data?.items ?? []).map((entry, i) => ([ + `ctrl+${i + 1}`, + (e: KeyboardEvent) => { + console.log("entry", entry) + e.stopPropagation() + onClose() + setSearchValue("") + navigate(`/events/e/${eventId}/lists/status/${entry.id}`, {replace: true}) + } + ]) + ) + )} + /> + +
+ {userQuery.data?.items.map((e, i) => ( + { + setSearchValue("") + onClose() + }} + replace={true} + > +
+ {getUserName(e.expand?.user)} + {e.listName} + + {pprintDateRange(e.slotStartDate, e.slotEndDate)} + {" • "} + {humanDeltaFromNow(e.slotStartDate, e.slotEndDate).message} + +
+
+ + CTRL + {i + 1} + +
+ + ))} +
+ {debouncedSearchValue.length > 0 && + ( + + { + !userQuery.data?.totalItems ? + userQuery.isPending ? "Suche..." : + "Keine Anmeldungen gefunden" : + `${userQuery.data?.totalItems} Anmeldung(en) gefunden` + } + + )} +
+
+} \ No newline at end of file diff --git a/src/pages/events/StatusEditor/QrCodeModal.tsx b/src/pages/events/StatusEditor/QrCodeModal.tsx new file mode 100644 index 0000000..09f5dd1 --- /dev/null +++ b/src/pages/events/StatusEditor/QrCodeModal.tsx @@ -0,0 +1,170 @@ +import {ActionIcon, Group, Modal, useMantineTheme} from "@mantine/core"; +import {IDetectedBarcode, IPoint, Scanner} from "@yudiel/react-qr-scanner"; +import {IconBulb, IconBulbOff, IconVolume, IconVolumeOff, IconX} from "@tabler/icons-react"; +import {useNavigate} from "react-router-dom"; +import {useQRCodeScannerSettings} from "@/lib/util.ts"; +import {useEffect} from "react"; + +const REGEX = /\/events\/e\/(?[a-zA-Z0-9]+)\/lists\/status\/(?[a-zA-Z0-9]+)/; + + +/** + * Draws text in the middle of a rectangle defined by an array of IPoint. + * @param ctx - The canvas rendering context. + * @param points - The array of points defining the rectangle. + * @param text - The text to draw. + * @param textColor - The color of the text. + * @param backgroundColor - The background color behind the text. + */ +function drawCenteredText(ctx: CanvasRenderingContext2D, points: IPoint[], text: string, textColor: string, backgroundColor: string): void { + // Calculate the bounding box + const xValues = points.map(point => point.x); + const yValues = points.map(point => point.y); + + const minX = Math.min(...xValues); + const maxX = Math.max(...xValues); + const minY = Math.min(...yValues); + const maxY = Math.max(...yValues); + + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + + // Set text properties to measure text width and height + ctx.font = '10px Overpass'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + // Measure text width and height + const textMetrics = ctx.measureText(text); + const textWidth = textMetrics.width; + const textHeight = textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent; + + // Draw the background rectangle + ctx.fillStyle = backgroundColor; + ctx.fillRect(centerX - textWidth / 2 - 5, centerY - textHeight / 2 - 5, textWidth + 10, textHeight + 10); + + // Draw the text + ctx.fillStyle = textColor; + ctx.fillText(text, centerX, centerY); +} + +function outlineQrCodes(detectedCodes: IDetectedBarcode[], ctx: CanvasRenderingContext2D) { + for (const detectedCode of detectedCodes) { + const [firstPoint, ...otherPoints] = detectedCode.cornerPoints; + ctx.lineWidth = 1.5; + + // only paint qr codes that match the regex + if (detectedCode.rawValue.match(REGEX)) { + ctx.strokeStyle = '#4dabf7'; + } else { + ctx.strokeStyle = '#ff6b6b'; + drawCenteredText(ctx, otherPoints, "Invalide", "#ff6b6b", "#fff"); + } + + ctx.beginPath(); + ctx.moveTo(firstPoint.x, firstPoint.y); + for (const {x, y} of otherPoints) { + ctx.lineTo(x, y); + } + ctx.lineTo(firstPoint.x, firstPoint.y); + ctx.closePath(); + ctx.stroke(); + } +} + +export default function QRCodeModal({opened, onClose}: { + opened: boolean, + onClose: () => void, +}) { + + + const [settings, setSettings] = useQRCodeScannerSettings() + useEffect(() => undefined, [settings]); + + const navigate = useNavigate() + + const theme = useMantineTheme() + return +
+ { + if (result?.[0]?.rawValue) { + const match = result[0].rawValue.match(REGEX); + if (match) { + const event_id = match.groups?.event_id; + const status_id = match.groups?.status_id; + + onClose() + navigate(`/events/e/${event_id}/lists/status/${status_id}`, {replace: true}); + } + } + }} + components={{ + audio: settings.sound, + onOff: settings.sound, + torch: settings.torch, + tracker: outlineQrCodes, + finder: false + }} + styles={{ + container: { + borderRadius: theme.radius.md, + overflow: "hidden", + width: "100%", + height: "100%", + margin: 'auto' + } + }} + allowMultiple={true} + /> + + + { + setSettings({ + torch: !settings.torch + }) + }} + color={settings.torch ? "yellow" : "gray"} + aria-label={"flash"} + > + {settings.torch ? : } + + + { + setSettings({ + sound: !settings.sound + }) + }} + color={settings.sound ? "blue" : "gray"} + aria-label={"scan sound"} + > + {settings.sound ? : } + + + + + + +
+
+} \ No newline at end of file diff --git a/src/pages/events/StatusEditor/StatusEditor.tsx b/src/pages/events/StatusEditor/StatusEditor.tsx new file mode 100644 index 0000000..5ad3fad --- /dev/null +++ b/src/pages/events/StatusEditor/StatusEditor.tsx @@ -0,0 +1,58 @@ +import {usePB} from "@/lib/pocketbase.tsx"; +import {Link, Navigate, Outlet, Route, Routes, useParams} from "react-router-dom"; +import {useQuery} from "@tanstack/react-query"; +import {useEventRights} from "@/pages/events/util.ts"; +import {Anchor, Breadcrumbs, LoadingOverlay} from "@mantine/core"; +import NotFound from "@/pages/not-found/index.page.tsx"; +import Index from "@/pages/events/StatusEditor/EditEntryStatus"; +import EntrySearch from "@/pages/events/StatusEditor/EntrySearch.tsx"; + +export default function StatusEditor() { + + const {pb} = usePB() + const {eventId} = useParams() as { eventId: string } + + const eventQuery = useQuery({ + queryKey: ["event", eventId], + queryFn: async () => (await pb.collection("events").getOne(eventId, { + expand: "eventAdmins, privilegedLists, privilegedLists.eventListSlots_via_eventList.eventListSlotEntries_via_eventListsSlot" + })) + }) + + const {isPrivilegedUser, canEditEvent} = useEventRights(eventQuery.data) + + if (eventQuery.isLoading) { + return + } + + if (eventQuery.isError || !eventQuery.data) { + return + } + + if (!(canEditEvent || isPrivilegedUser)) { + return + } + + const event = eventQuery.data + + return <> +
+ {[ + {title: "Home", to: "/"}, + {title: "Events", to: "/events"}, + {title: event.name, to: `/events/${event.id}`}, + {title: "Status Editor", to: `/events/e/${event.id}/lists/status`}, + ].map(({title, to}) => ( + + {title} + + ))} +
+ + + }/> + }/> + + + +} \ No newline at end of file diff --git a/src/pages/events/e/:eventId/EditEventRouter.tsx b/src/pages/events/e/:eventId/EditEventRouter.tsx index ae26aab..c553eff 100644 --- a/src/pages/events/e/:eventId/EditEventRouter.tsx +++ b/src/pages/events/e/:eventId/EditEventRouter.tsx @@ -163,7 +163,6 @@ export default function EditEventRouter() {
- diff --git a/src/pages/events/e/:eventId/EventLists/EventListsRouter.tsx b/src/pages/events/e/:eventId/EventLists/EventListsRouter.tsx index 5241826..d358638 100644 --- a/src/pages/events/e/:eventId/EventLists/EventListsRouter.tsx +++ b/src/pages/events/e/:eventId/EventLists/EventListsRouter.tsx @@ -19,15 +19,20 @@ const viewNav = [ { label: "Listenaktionen", children: [ - { - title: "Listen und Slots", - icon: , - to: "overview" - }, { title: "Anmeldungen", icon: , to: "search" + }, + { + title: "Status-Editor", + icon: , + to: "status" + }, + { + title: "Listen und Slots", + icon: , + to: "overview" } ] }, diff --git a/src/pages/events/e/:eventId/EventLists/Search/index.tsx b/src/pages/events/e/:eventId/EventLists/Search/index.tsx index 3123d81..20fb141 100644 --- a/src/pages/events/e/:eventId/EventLists/Search/index.tsx +++ b/src/pages/events/e/:eventId/EventLists/Search/index.tsx @@ -66,7 +66,19 @@ export default function ListSearch({event}: { event: EventModel }) { const filter: string[] = [`event='${event.id}'`] // filter for user by name - filterValues.searchQueryString && filter.push(`user.username ~ '${filterValues.searchQueryString.trim().replace(" ", ".")}'`) + if (filterValues.searchQueryString) { + const usernameQuery = filterValues.searchQueryString.trim() + const givenName = filterValues.searchQueryString.split(" ")[1]?.trim() ?? null + const surname = filterValues.searchQueryString.split(" ")[0]?.trim() ?? givenName + + const nameFilter = [] as string[] + + usernameQuery && nameFilter.push(`user.username~'${usernameQuery}'`) + surname && nameFilter.push(`user.sn~'${surname}'`) + givenName && nameFilter.push(`user.givenName~'${givenName}'`) + + filter.push(`(${nameFilter.join("||")})`) + } // filter for lists filterValues.selectedLists.length > 0 && filter.push(`(${filterValues.selectedLists.map(l => `eventList='${l.id}'`).join(" || ")})`) @@ -100,7 +112,7 @@ export default function ListSearch({event}: { event: EventModel }) { initialPageParam: 1, }) - // this string informs the user about the current list selection +// this string informs the user about the current list selection const listInfoString = `${formValues.values.selectedLists.length > 0 ? ` in den Listen ${formValues.values.selectedLists.map(l => `'${l.name}'`).join(", ")}` : ""}` const entries = query.data?.pages.flatMap(p => p.items) ?? [] diff --git a/src/pages/events/entries/UserEntryRow.tsx b/src/pages/events/entries/UserEntryRow.tsx index f583874..9861ae0 100644 --- a/src/pages/events/entries/UserEntryRow.tsx +++ b/src/pages/events/entries/UserEntryRow.tsx @@ -1,6 +1,24 @@ import {EventListSlotEntriesWithUserModel} from "@/models/EventTypes.ts"; -import {ActionIcon, Collapse, Group, Text, ThemeIcon, Tooltip} from "@mantine/core"; -import {IconChevronDown, IconChevronRight, IconConfetti, IconForms, IconList, IconTrash} from "@tabler/icons-react"; +import { + ActionIcon, + Collapse, + Group, + Modal, + Text, + ThemeIcon, + Tooltip, + useMantineColorScheme, + useMantineTheme +} from "@mantine/core"; +import { + IconChevronDown, + IconChevronRight, + IconConfetti, + IconForms, + IconList, + IconQrcode, + IconTrash +} from "@tabler/icons-react"; import TextWithIcon from "@/components/layout/TextWithIcon"; @@ -19,6 +37,9 @@ import { EntryQuestionAndStatusData } from "@/pages/events/e/:eventId/EventLists/EventListComponents/EntryQuestionAndStatusData.tsx"; import {Link} from "react-router-dom"; +import {createQRCodeUrl} from "@/lib/util.ts"; +import {APP_URL} from "../../../../config.ts"; +import ShowHelp from "@/components/ShowHelp.tsx"; export default function UserEntryRow({entry, refetch}: { @@ -29,6 +50,8 @@ export default function UserEntryRow({entry, refetch}: { const [expanded, expandedHandler] = useDisclosure(false) + const [showQrCode, showQrCodeHandler] = useDisclosure(false) + const [showEditFormModal, showEditFormModalHandler] = useDisclosure(false) const delta = humanDeltaFromNow(entry.slotStartDate, entry.slotEndDate) @@ -49,6 +72,15 @@ export default function UserEntryRow({entry, refetch}: { onConfirm: () => deleteEntryMutation.mutate() }) + const theme = useMantineTheme() + const {colorScheme} = useMantineColorScheme() + + const qrCodeLink = createQRCodeUrl({ + data: `${APP_URL}/events/e/${entry.event}/lists/status/${entry.id}`, + color: colorScheme === "dark" ? theme.white : theme.colors.dark[8], + colorBackground: colorScheme === "dark" ? theme.colors.dark[7] : "" + }) + return
@@ -60,6 +92,13 @@ export default function UserEntryRow({entry, refetch}: { entry={entry} /> + + {"QR + + Mit diesem Code können die Veranstalter:innen des Events deinen Status bearbeiten. + + +
@@ -81,7 +120,7 @@ export default function UserEntryRow({entry, refetch}: { }> - + {entry.listName} @@ -95,6 +134,16 @@ export default function UserEntryRow({entry, refetch}: {
+ + + + + diff --git a/src/pages/home/index.page.tsx b/src/pages/home/index.page.tsx index 498367f..3e05496 100644 --- a/src/pages/home/index.page.tsx +++ b/src/pages/home/index.page.tsx @@ -6,6 +6,7 @@ import {NavLink} from "react-router-dom"; import Announcements from "@/pages/chat/components/Announcements.tsx"; import classes from './index.module.css' import {ReactNode} from "react"; +import {getUserName} from "@/components/users/modals/util.tsx"; const NavButtons = ({buttons}: { buttons: { title: string, icon: ReactNode, to: string }[] @@ -44,7 +45,7 @@ export default function HomePage() { Hallo - {user && `, ${user.username}`} + {user && `, ${getUserName(user)}`} {!user && <> diff --git a/src/style/global.css b/src/style/global.css index 0c1317b..f69210d 100644 --- a/src/style/global.css +++ b/src/style/global.css @@ -67,10 +67,12 @@ a { text-decoration: unset; /* Removes default underline */ + color: revert; } a:hover, a:active, a:visited { text-decoration: unset; /* Removes default underline */ + color: unset; } .section-icon { diff --git a/yarn.lock b/yarn.lock index f8da1fa..c024494 100644 --- a/yarn.lock +++ b/yarn.lock @@ -989,6 +989,16 @@ dependencies: "@types/ms" "*" +"@types/dom-webcodecs@^0.1.11": + version "0.1.11" + resolved "https://registry.yarnpkg.com/@types/dom-webcodecs/-/dom-webcodecs-0.1.11.tgz#2e36e5cc71789551f107e2fe15d956845fa19567" + integrity sha512-yPEZ3z7EohrmOxbk/QTAa0yonMFkNkjnVXqbGb7D4rMr+F1dGQ8ZUFxXkyLLJuiICPejZ0AZE9Rrk9wUCczx4A== + +"@types/emscripten@^1.39.13": + version "1.39.13" + resolved "https://registry.yarnpkg.com/@types/emscripten/-/emscripten-1.39.13.tgz#afeb1648648dc096efe57983e20387627306e2aa" + integrity sha512-cFq+fO/isvhvmuP/+Sl4K4jtU6E23DoivtbO4r50e3odaxAiVdbfSYRDdJ4gCdxx+3aRjhphS5ZMwIH4hFy/Cw== + "@types/estree-jsx@^1.0.0": version "1.0.5" resolved "https://registry.yarnpkg.com/@types/estree-jsx/-/estree-jsx-1.0.5.tgz#858a88ea20f34fe65111f005a689fa1ebf70dc18" @@ -1217,6 +1227,14 @@ dependencies: "@swc/core" "^1.3.85" +"@yudiel/react-qr-scanner@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@yudiel/react-qr-scanner/-/react-qr-scanner-2.0.2.tgz#0fa54e5f8365a69d1a5bd6f5fb024167bea9ab57" + integrity sha512-62xxXQVGkh813MZ3aTQiHMP33CAUJicGb2aRFOt4Vtmna+SR67v1wX3ip7UVcoBxwjuwDTfVvPxWquCvjcBYQg== + dependencies: + barcode-detector "^2.2.6" + webrtc-adapter "9.0.1" + acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" @@ -1289,6 +1307,14 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +barcode-detector@^2.2.6: + version "2.2.7" + resolved "https://registry.yarnpkg.com/barcode-detector/-/barcode-detector-2.2.7.tgz#ab47e3e36727a9f5d1e7183240f88831f2451efa" + integrity sha512-+6PJNcMtdVehX5i2LQUE9L+mS6C3cG00Vsuc4Ynj3Mls5GNKIAFkE0IFGtw4s6vu8SXeogrzTj4btm44oD+gNw== + dependencies: + "@types/dom-webcodecs" "^0.1.11" + zxing-wasm "1.2.11" + binary-extensions@^2.0.0: version "2.3.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" @@ -3512,6 +3538,11 @@ scheduler@^0.23.0: dependencies: loose-envify "^1.1.0" +sdp@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/sdp/-/sdp-3.2.0.tgz#8961420552b36663b4d13ddba6f478d1461896a5" + integrity sha512-d7wDPgDV3DDiqulJjKiV2865wKsJ34YI+NDREbm+FySq6WuKOikwyNQcm+doLAZ1O6ltdO0SeKle2xMpN3Brgw== + semver@^6.3.1: version "6.3.1" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" @@ -3924,6 +3955,13 @@ warning@^4.0.3: dependencies: loose-envify "^1.0.0" +webrtc-adapter@9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/webrtc-adapter/-/webrtc-adapter-9.0.1.tgz#d4efa22ca9604cb2c8cdb9e492815ba37acfa0b2" + integrity sha512-1AQO+d4ElfVSXyzNVTOewgGT/tAomwwztX/6e3totvyyzXPvXIIuUUjAmyZGbKBKbZOXauuJooZm3g6IuFuiNQ== + dependencies: + sdp "^3.2.0" + which@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" @@ -3960,3 +3998,10 @@ zwitch@^2.0.0: version "2.0.4" resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7" integrity sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A== + +zxing-wasm@1.2.11: + version "1.2.11" + resolved "https://registry.yarnpkg.com/zxing-wasm/-/zxing-wasm-1.2.11.tgz#8263ba27ea6d209d7a8440fac15a1fb6838d4630" + integrity sha512-rNSMkIU310sK5cCPSjZA58FEhGZUtNx+f0CmtZ3SZzpdwZE6IzzKFdkbFkl8CFnxiUrx1VMVl/2WULDnwwJbfg== + dependencies: + "@types/emscripten" "^1.39.13"