feat(statusEditor): added StatusEditor.tsx with mobile first design
Build and Push Docker image / build-and-push (push) Successful in 5m36s Details

This commit is contained in:
Valentin Kolb 2024-06-10 19:49:42 +02:00
parent 4ea290ddf4
commit 7cef873acd
20 changed files with 916 additions and 70 deletions

View File

@ -2,9 +2,11 @@
<html lang="de">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"/>
<meta name="theme-color" content="#339af0">
<link rel="icon" href="/stuve-logo.svg"/>
<title>StuVe IT</title>
<meta name="description" content="IT-Tools der StuVe Uni Ulm (K.d.ö.R)">
</head>
<body>
<div id="root"></div>

View File

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

View File

@ -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 <>
<Modal opened={value && !!user} onClose={handler.close} withCloseButton={false} size={"md"}>
<Modal opened={(value && !!user) || userHasNoName} onClose={handler.close} withCloseButton={false} size={"md"}>
<div className={classes.stack}>
<Title order={3}>Hallo {user?.username}</Title>
{userHasNoName && (
<Alert color={"orange"} title={"Gebe deinen Vor- und Nachnamen ein"}>
Bitte vervollständige deinen Account, um alle Funktionen nutzen zu können.
</Alert>
)}
<Title order={3}>Hallo {getUserName(user)}</Title>
<ShowDebug>
Datenbank ID: <Code>{user?.id}</Code>
User ID: <Code>{user?.id}</Code>
<br/>
REALM: <Code>{user?.REALM}</Code>
{user?.objectGUID && <>
@ -50,43 +103,25 @@ export default function UserMenuModal() {
</ShowDebug>
<div className={classes.row}>
<ThemeIcon
variant={"transparent"}
size={"xl"}
>
<IconUser/>
</ThemeIcon>
<Text>
{user?.REALM === "LDAP" ? "StuVe IT Account" : "Gast Account"}
</Text>
</div>
<div className={classes.row}>
<ThemeIcon
variant={"transparent"}
size={"xl"}
>
<ThemeIcon variant={"transparent"}>
<IconAt/>
</ThemeIcon>
<Tooltip label={`Dein Email ist ${user?.emailVisibility ? "sichtbar" : "nicht sichtbar"}`}>
<Text>
<Tooltip
label={`Dein Email ist ${user?.emailVisibility ? "für niemanden sichtbar" : "für eingeloggte Personen sichtbar"}`}
>
<Text size={"sm"}>
{user?.email}
</Text>
</Tooltip>
</div>
<div className={classes.row}>
<ThemeIcon
variant={"transparent"}
size={"xl"}
>
<ThemeIcon variant={"transparent"}>
<IconCalendar/>
</ThemeIcon>
<Text>
<Text size={"sm"}>
{user?.accountExpires ? (
new Date(user?.accountExpires).getTime() > Date.now() ? (
@ -105,7 +140,6 @@ export default function UserMenuModal() {
<ThemeIcon
variant={"transparent"}
color={"green"}
size={"xl"}
>
<IconServer/>
</ThemeIcon>
@ -113,13 +147,12 @@ export default function UserMenuModal() {
<ThemeIcon
variant={"transparent"}
color={"red"}
size={"xl"}
>
<IconServerOff/>
</ThemeIcon>
)}
<Text>
<Text size={"sm"}>
{apiIsHealthy ? "Das Backend ist erreichbar" : "Das Backend ist nicht erreichbar"}
</Text>
</div>
@ -147,6 +180,7 @@ export default function UserMenuModal() {
<Switch
checked={showDebug}
onChange={toggleShowDebug}
color={"orange"}
label={"Debug Modus aktivieren"}
/>
@ -159,9 +193,47 @@ export default function UserMenuModal() {
label={"Account"}
/>
<Group justify={"center"}>
{userHasNoName &&
<Alert> Bitte vervollständige deinen Account, um alle Funktionen nutzen zu können. </Alert>}
{
user?.REALM === "GUEST" && <>
<PocketBaseErrorAlert error={mutation.error}/>
<TextInput
label={"Vorname"}
description={"Dein Vorname, alle Personen mit Account können diesen sehen"}
placeholder={"Vorname"}
{...formValues.getInputProps("givenName")}
required
/>
<TextInput
label={"Nachname"}
description={"Dein Nachname, alle Personen mit Account können diesen sehen"}
placeholder={"Nachname"}
{...formValues.getInputProps("sn")}
required
/>
</>
}
<Group justify={"center"}>
{user?.REALM === "GUEST" && <>
<Tooltip label={"Vor- und Nachname speichern"}>
<ActionIcon
variant={"transparent"}
aria-label={"change email"}
onClick={() => {
mutation.mutate()
}}
color={"green"}
disabled={!formValues.isTouched()}
>
<IconDeviceFloppy/>
</ActionIcon>
</Tooltip>
<Tooltip label={"Email ändern"}>
<ActionIcon
variant={"transparent"}
@ -189,7 +261,8 @@ export default function UserMenuModal() {
</Tooltip>
</>}
<Tooltip label={"Anmeldedaten neu laden"}>
{showDebug &&
<Tooltip color={"orange"} label={"Debug: Anmeldedaten neu laden"}>
<ActionIcon
variant={"transparent"}
color={"orange"}
@ -201,6 +274,7 @@ export default function UserMenuModal() {
<IconRefresh/>
</ActionIcon>
</Tooltip>
}
<Tooltip label={"Ausloggen"}>
<ActionIcon

View File

@ -1,4 +1,5 @@
import {PB_BASE_URL} from "../../config.ts";
import {useLocalStorage} from "@mantine/hooks";
/**
* This function creates a query string from an object.
@ -31,15 +32,51 @@ export type ErrorCorrectionLevel = "L" | "M" | "Q" | "H"
*/
export const createQRCodeUrl = (options: {
data: string,
ecc: ErrorCorrectionLevel,
scale: number,
border: number,
color: string,
colorBackground: string
ecc?: ErrorCorrectionLevel,
scale?: number,
border?: number,
color?: string,
colorBackground?: string
}): string => {
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<QRCodeScannerSettings>) => 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<QRCodeScannerSettings>) => _setValue(JSON.stringify({...value, ...settings}))
return [value, setValue]
}
export const flattenReducer = <T>(acc: T[], val: T | T[]): T[] => {
if (Array.isArray(val)) {
return [...acc, ...val]

View File

@ -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() {
<Route path={":eventId"} element={<EventNavigate/>}/>
<Route path={"s/:eventId/*"} element={<EventView/>}/>
<Route path={"e/:eventId/*"} element={<EditEventRouter/>}/>
<Route path={"e/:eventId/lists/status/*"} element={<StatusEditor/>}/>
<Route path={"entries"} element={<UserEntries/>}/>
<Route path={"*"} element={<NotFound/>}/>
</Routes>

View File

@ -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 <>
<div className={"section-transparent"}>
<PocketBaseErrorAlert error={mutation.error}/>
</div>
<div className={"section-transparent"}>
<FormInput
schema={statusSchema}
onSubmit={mutation.mutateAsync}
onAbort={() => navigate(`/events/e/${eventId}/lists/status`, {replace: true})}
initialData={entry.entryStatusData ?? undefined}
/>
</div>
</>
}

View File

@ -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 <>
<div className={"section-transparent"}>
<EntryStatusSpoiler entry={entry}/>
</div>
<div className={"section-transparent"}>
<Group>
<Button
leftSection={<IconCheckupList/>}
component={Link}
to={`/events/e/${entry.event}/lists/status/${entry.id}/e`}
>
Bearbeiten
</Button>
<Button
leftSection={<IconCheck/>}
component={Link}
to={`/events/e/${entry.event}/lists/status`}
>
Fertig
</Button>
</Group>
</div>
</>
}

View File

@ -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 <Center>
<Loader/>
</Center>
}
if (entryQuery.isError || !entry) {
return <PocketBaseErrorAlert error={entryQuery.error}/>
}
return <>
<div className={"section stack"}>
<Title c={"blue"} order={1} size={"md"}>
{getUserName(entry.expand?.user)}
</Title>
<Divider/>
<Text>{entry.listName}</Text>
<Group>
<RenderDateRange start={new Date(entry.slotStartDate)} end={new Date(entry.slotEndDate)}/>
({humanDeltaFromNow(entry.slotStartDate, entry.slotEndDate).message})
</Group>
{
(entry.listDescription || entry.slotDescription) && <>
<Group>
<Button
size={"xs"}
variant={"light"}
onClick={toggle}
leftSection={showDescription ? <IconEyeOff/> : <IconEye/>}
>
Beschreibung
</Button>
</Group>
<Collapse in={showDescription}>
<>
{entry.listDescription && <InnerHtml html={entry.listDescription}/>}
{entry.slotDescription && <InnerHtml html={entry.slotDescription}/>}
</>
</Collapse>
</>
}
</div>
<Routes>
<Route index element={<Navigate to={"e"} replace/>}/>
<Route path={"e"} element={<EditStatus entry={entry} refetch={entryQuery.refetch}/>}/>
<Route path={"s"} element={<ShowStatus entry={entry}/>}/>
<Route path={"*"} element={<NotFound/>}/>
</Routes>
</>
}

View File

@ -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 <>
<QRCodeModal opened={showQrCodeModal} onClose={showQrCodeModalHandler.close}/>
<EntrySearchModal opened={showEntrySearchModal} onClose={showEntrySearchModalHandler.close}/>
<Center>
<SearchSVG height={"300px"} width={"300px"}/>
</Center>
<div className={" center"}>
<Group justify={"center"}>
<ActionIcon
variant="light" size="xl" radius="xl"
aria-label={"search for user"}
onClick={showEntrySearchModalHandler.open}
>
<IconUserSearch/>
</ActionIcon>
<Text size={"xs"} c={"dimmed"}>
oder
</Text>
<ActionIcon
variant="light" size="xl" radius="xl"
aria-label={"scan qr code"}
onClick={showQrCodeModalHandler.open}
>
<IconQrcode/>
</ActionIcon>
</Group>
</div>
</>
}

View File

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

View File

@ -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 <Modal
size={"xl"}
opened={opened}
onClose={onClose}
withCloseButton={false}
centered
classNames={classes}
styles={{}}
>
<div className={classes.container}>
<TextInput
size={"lg"}
variant={"unstyled"}
leftSection={<IconUserSearch/>}
rightSection={userQuery.isLoading ? <Loader size={"xs"}/> : searchValue.length !== 0 ? (
<ActionIcon
variant={"transparent"}
onClick={() => setSearchValue('')}
aria-label={"Clear"}
>
<IconX/>
</ActionIcon>
) : 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})
}
])
)
)}
/>
<PocketBaseErrorAlert error={userQuery.error}/>
<div className={"scrollbar scrollable"}>
{userQuery.data?.items.map((e, i) => (
<Link
to={`/events/e/${eventId}/lists/status/${e.id}`}
key={e.id}
className={`${classes.entry} hover`}
onClick={() => {
setSearchValue("")
onClose()
}}
replace={true}
>
<div className={classes.entryDetails}>
<Text>{getUserName(e.expand?.user)}</Text>
<Text size={"xs"} c={"dimmed"}>{e.listName}</Text>
<Text size={"xs"} c={"dimmed"}>
{pprintDateRange(e.slotStartDate, e.slotEndDate)}
{" • "}
{humanDeltaFromNow(e.slotStartDate, e.slotEndDate).message}
</Text>
</div>
<div>
<Kbd size={"xs"} c={"dimmed"}>
CTRL + {i + 1}
</Kbd>
</div>
</Link>
))}
</div>
{debouncedSearchValue.length > 0 &&
(
<Text
p={"sm"}
size={"xs"}
ta={"center"}
c={"dimmed"}
>
{
!userQuery.data?.totalItems ?
userQuery.isPending ? "Suche..." :
"Keine Anmeldungen gefunden" :
`${userQuery.data?.totalItems} Anmeldung(en) gefunden`
}
</Text>
)}
</div>
</Modal>
}

View File

@ -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\/(?<event_id>[a-zA-Z0-9]+)\/lists\/status\/(?<status_id>[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 <Modal
size={"xl"}
opened={opened}
onClose={onClose}
withCloseButton={false}
centered
styles={{
content: {
background: "none",
boxShadow: "none"
}
}}
>
<div className={"stack"}>
<Scanner
key={JSON.stringify(settings)}
paused={!opened}
onScan={(result) => {
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}
/>
<Group justify="center">
<ActionIcon
onClick={() => {
setSettings({
torch: !settings.torch
})
}}
color={settings.torch ? "yellow" : "gray"}
aria-label={"flash"}
>
{settings.torch ? <IconBulb/> : <IconBulbOff/>}
</ActionIcon>
<ActionIcon
onClick={() => {
setSettings({
sound: !settings.sound
})
}}
color={settings.sound ? "blue" : "gray"}
aria-label={"scan sound"}
>
{settings.sound ? <IconVolume/> : <IconVolumeOff/>}
</ActionIcon>
<ActionIcon
onClick={onClose}
aria-label={"close"}
color={"gray"}
>
<IconX/>
</ActionIcon>
</Group>
</div>
</Modal>
}

View File

@ -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 <LoadingOverlay/>
}
if (eventQuery.isError || !eventQuery.data) {
return <NotFound/>
}
if (!(canEditEvent || isPrivilegedUser)) {
return <Navigate to={`/events/s/${eventId}`} replace/>
}
const event = eventQuery.data
return <>
<div className={"section-transparent"}>
<Breadcrumbs>{[
{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}) => (
<Anchor component={Link} to={to} key={title}>
{title}
</Anchor>
))}</Breadcrumbs>
</div>
<Routes>
<Route index element={<EntrySearch/>}/>
<Route path={":entryId/*"} element={<Index/>}/>
</Routes>
<Outlet/>
</>
}

View File

@ -163,7 +163,6 @@ export default function EditEventRouter() {
<EventFavourites event={event}/>
</div>
</Grid.Col>
<Grid.Col span={{base: 12, sm: 9}} className={"stack"}>
<Group gap={"sm"} align={"center"}>
<NavLink to={`/events/e/${eventId}/description`} replace className={classes.navLink}>

View File

@ -19,15 +19,20 @@ const viewNav = [
{
label: "Listenaktionen",
children: [
{
title: "Listen und Slots",
icon: <IconList size={16}/>,
to: "overview"
},
{
title: "Anmeldungen",
icon: <IconUserSearch size={16}/>,
to: "search"
},
{
title: "Status-Editor",
icon: <IconCheckupList size={16}/>,
to: "status"
},
{
title: "Listen und Slots",
icon: <IconList size={16}/>,
to: "overview"
}
]
},

View File

@ -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) ?? []

View File

@ -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 <div className={classes.container}>
<ConfirmModal/>
@ -60,6 +92,13 @@ export default function UserEntryRow({entry, refetch}: {
entry={entry}
/>
<Modal opened={showQrCode} onClose={showQrCodeHandler.close} title={"Status Code"}>
<img src={qrCodeLink} alt={"QR Code Vorschau"}/>
<ShowHelp>
Mit diesem Code können die Veranstalter:innen des Events deinen Status bearbeiten.
</ShowHelp>
</Modal>
<div className={classes.row}>
<Tooltip label={expanded ? "Details verbergen" : "Details anzeigen"} withArrow>
<ActionIcon onClick={expandedHandler.toggle} variant={"light"} size={"xs"}>
@ -81,7 +120,7 @@ export default function UserEntryRow({entry, refetch}: {
<IconList/>
</ThemeIcon>
}>
<Link to={`/events/s/${entry.event}?lists=${entry.eventList}`} >
<Link to={`/events/s/${entry.event}?lists=${entry.eventList}`}>
{entry.listName}
</Link>
</TextWithIcon>
@ -95,6 +134,16 @@ export default function UserEntryRow({entry, refetch}: {
</div>
<Group gap={"xs"}>
<ActionIcon
variant={"light"}
size={"xs"}
onClick={showQrCodeHandler.toggle}
aria-label={"show qr code"}
>
<IconQrcode/>
</ActionIcon>
<Tooltip
label={delta.delta === "PAST" ? "Vergangene Anmeldungen können nicht bearbeiten werden" : "Eintrag bearbeiten"}
withArrow>

View File

@ -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() {
</ThemeIcon>
Hallo
{user && `, ${user.username}`}
{user && `, ${getUserName(user)}`}
</Title>
{!user && <>

View File

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

View File

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