diff --git a/.env.example b/.env.example index c53692cf..b8e2c52f 100644 --- a/.env.example +++ b/.env.example @@ -1,26 +1,40 @@ # NB: These are _not_ prefixed with NEXT_PUBLIC because we don't want their values to be sewn into the Docker image # Base configuration -DATABASE_URL="postgresql://postgres:postgres@localhost:5432/calendar_2023" +DATABASE_URL="postgresql://postgres:postgres@localhost:5432/internal_site" PUBLIC_URL=http://localhost:3000 SESSION_SECRET= # A random string, in production should be a secure secret e.g. `openssl rand -base64 32` # Google login configuration GOOGLE_CLIENT_ID= -GOOGLE_PERMITTED_DOMAINS=york.ac.uk +GOOGLE_PERMITTED_DOMAINS=york.ac.uk,ystv.co.uk + +# Needed for youtube integration, optional otherwise. This is *not* the client secret! +GOOGLE_API_KEY= # AdamRMS configuration ADAMRMS_EMAIL= ADAMRMS_PASSWORD= ADAMRMS_BASE= ADAMRMS_PROJECT_TYPE_ID= + # Slack configuration SLACK_ENABLED=false +SESSION_SECRET= +COOKIE_DOMAIN=localhost +SESSION_SECRET= +SLACK_ENABLED= SLACK_BOT_TOKEN= SLACK_APP_TOKEN= SLACK_SIGNING_SECRET= SLACK_CLIENT_ID= SLACK_CLIENT_SECRET= -SLACK_TEAM_ID= # Should be set only if the slack integration is used across multiple workspaces -SLACK_USER_FEEDBACK_CHANNEL= -SENTRY_PROJECT_ID= +# SLACK_BOT_TOKEN= +# SLACK_APP_TOKEN= +# SLACK_SIGNING_SECRET= +# SLACK_CLIENT_ID= +# SLACK_CLIENT_SECRET= +# Should be set if the slack integration is used across multiple workspaces +SLACK_TEAM_ID= +# Use https://www.streamweasels.com/tools/youtube-channel-id-and-user-id-convertor/ to convert +YOUTUBE_CHANNEL_ID=UCwViVJcFiwBSDmzhaHiagqw diff --git a/.envrc b/.envrc new file mode 100644 index 00000000..8392d159 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json index ce25b246..6f7e20d7 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -2,7 +2,8 @@ "extends": [ "next/core-web-vitals", "prettier", - "plugin:storybook/recommended" + "plugin:storybook/recommended", + "plugin:@tanstack/query/recommended" ], "plugins": ["@typescript-eslint"], "rules": { diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..725479b3 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,17 @@ +https://linear.app/ystv/issue/INT-XXX + +## What + + + +## Why + + + +## How + + + +## Testing + + diff --git a/.gitignore b/.gitignore index cfef2a24..0e2beccb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /node_modules /.pnp .pnp.js +/.direnv # testing /coverage @@ -48,4 +49,6 @@ next-env.d.ts # Sentry Auth Token .sentryclirc -certificates \ No newline at end of file +certificates +# Sentry Config File +.env.sentry-build-plugin diff --git a/Dockerfile b/Dockerfile index e2631cb3..5ea705a8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,19 +4,22 @@ FROM node:20-bookworm-slim AS base RUN apt-get update -y && apt-get install -y ca-certificates git openssl FROM base AS build +RUN apt-get update -y && apt-get install -y build-essential python3 WORKDIR /app COPY ./.yarn/ .yarn/ COPY . /app/ -RUN --mount=type=cache,id=calendar2023-yarn,target=.yarn/cache yarn install --immutable --inline-builds +RUN --mount=type=cache,id=internal-site-yarn,target=.yarn/cache yarn install --immutable --inline-builds ENV NODE_ENV=production ARG GIT_REV ENV GIT_REV=$GIT_REV -ARG SENTRY_AUTH_TOKEN -ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN ARG VERSION ENV VERSION=$VERSION -RUN SKIP_ENV_VALIDATION=1 PUBLIC_URL="http://localhost:3000" yarn run build +RUN --mount=type=secret,id=sentry-auth-token \ + SENTRY_AUTH_TOKEN=$(cat /run/secrets/sentry-auth-token) \ + SKIP_ENV_VALIDATION=1 \ + PUBLIC_URL="http://localhost:3000" \ + yarn run build FROM base COPY --from=build /app/dist /app/dist diff --git a/Jenkinsfile b/Jenkinsfile index 77c8500d..83d3d43e 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -3,7 +3,10 @@ def imageTag = '' pipeline { agent { - label 'docker' + node { + label 'docker && ramdisk' + customWorkspace '/mnt/ramdisk/build/workspace/internal-site' + } } environment { @@ -25,14 +28,14 @@ pipeline { } stage('Build Images') { environment { - SENTRY_AUTH_TOKEN = credentials('calendar-sentry-auth-token') + SENTRY_AUTH_TOKEN = credentials('internal-site-sentry-auth-token') } steps { sh """docker build \\ --build-arg GIT_REV=${env.GIT_COMMIT} \\ --build-arg VERSION=${env.TAG_NAME ?: 'v0.0.0'} \\ - --build-arg SENTRY_AUTH_TOKEN=\$SENTRY_AUTH_TOKEN \\ - -t registry.comp.ystv.co.uk/ystv/calendar2023:${imageTag}\\ + --secret id=sentry-auth-token,env=SENTRY_AUTH_TOKEN \\ + -t registry.comp.ystv.co.uk/ystv/internal-site:${imageTag}\\ . """ } @@ -47,7 +50,7 @@ pipeline { } } steps { - dockerPush image: 'registry.comp.ystv.co.uk/ystv/calendar2023', tag: imageTag + dockerPush image: 'registry.comp.ystv.co.uk/ystv/internal-site', tag: imageTag } } @@ -56,7 +59,7 @@ pipeline { changeRequest target: 'main' } steps { - deployPreview action: 'deploy', job: 'calendar-preview', urlSuffix: 'internal.dev.ystv.co.uk' + deployPreview action: 'deploy', job: 'internal-site-preview', urlSuffix: 'internal.dev.ystv.co.uk' } } @@ -66,11 +69,12 @@ pipeline { } steps { build job: 'Deploy Nomad Job', parameters: [ - string(name: 'JOB_FILE', value: 'calendar-dev.nomad'), - text(name: 'TAG_REPLACEMENTS', value: "registry.comp.ystv.co.uk/ystv/calendar2023:${imageTag}") + string(name: 'JOB_FILE', value: 'internal-site-dev.nomad'), + text(name: 'TAG_REPLACEMENTS', value: "registry.comp.ystv.co.uk/ystv/internal-site:${imageTag}") ], wait: true deployPreview action: 'cleanup' - sh "nomad alloc exec -task calendar-dev -job calendar-dev npx -y prisma migrate deploy --schema lib/db/schema.prisma" + deployPreview action: 'cleanupMerge' + sh "nomad alloc exec -task internal-site-dev -job internal-site-dev npx -y prisma migrate deploy --schema lib/db/schema.prisma" } } @@ -81,10 +85,10 @@ pipeline { } steps { build job: 'Deploy Nomad Job', parameters: [ - string(name: 'JOB_FILE', value: 'calendar-prod.nomad'), - text(name: 'TAG_REPLACEMENTS', value: "registry.comp.ystv.co.uk/ystv/calendar2023:${imageTag}") + string(name: 'JOB_FILE', value: 'internal-site-prod.nomad'), + text(name: 'TAG_REPLACEMENTS', value: "registry.comp.ystv.co.uk/ystv/internal-site:${imageTag}") ], wait: true - sh "nomad alloc exec -task calendar-prod -job calendar-prod npx -y prisma migrate deploy --schema lib/db/schema.prisma" + sh "nomad alloc exec -task internal-site-prod -job internal-site-prod npx -y prisma migrate deploy --schema lib/db/schema.prisma" } } } diff --git a/README.md b/README.md index 8109219e..48f9aaf9 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ -# YSTV Calendar +# YSTV Internal Site This is the repo for https://internal.ystv.co.uk. Formerly known as `experimental-hypothetical-new-internal-site-idea`. ## Getting Started -To set up a local copy of the new calendar, you will need +To set up a local copy of the new internal site, you will need - Node.js (18 or later) - https://nodejs.org/en/download - Yarn - once you have Node installed, run `corepack enable` @@ -31,7 +31,7 @@ You will also need to set up the following: ### Postgres Database -Once you have PostgreSQL installed, run `createdb calendar_2023`. +Once you have PostgreSQL installed, run `createdb internal_site`. Now run `yarn prisma db push` to set up the database tables. If you get a permissions error, check your PostgreSQL authentication settings - you should have a `local all all peer` line in your pg_hba.conf. @@ -80,6 +80,14 @@ Open [http://localhost:3000](http://localhost:3000) or [https://localhost:3000]( To get admin permissions, sign in once with Google, then run `yarn do promoteUser `. +## Development + +There are some docs written for developing specific features but otherwise looking at the code and the [Next.js documentation](https://nextjs.org/docs) is the best place to get started. + +Feature specific docs: + +- [Socket.io communication](/docs/development/implementing_socket_io.md) + ## Structure - app/ - pages @@ -87,6 +95,6 @@ To get admin permissions, sign in once with Google, then run `yarn do promoteUse - lib/ - low level utilities (auth, db, etc.) - server/ - custom server that handles socket.io communication -## Development +## Contributing -We use [Linear](https://linear.app/ystv) to track issues - to access it, sign in with your @ystv.co.uk Google account (ask a Computing Team member if you don't have one). +Some documentation about how to contribute and some standards to follow is available [here](/docs/contributing.md) diff --git a/app/(authenticated)/(superuser)/admin/page.tsx b/app/(authenticated)/(superuser)/admin/page.tsx deleted file mode 100644 index fec0e9da..00000000 --- a/app/(authenticated)/(superuser)/admin/page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function AdminPage() { - return

Hello There!

; -} diff --git a/app/(authenticated)/(superuser)/admin/positions/page.tsx b/app/(authenticated)/(superuser)/admin/positions/page.tsx deleted file mode 100644 index b1c38598..00000000 --- a/app/(authenticated)/(superuser)/admin/positions/page.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { PositionView } from "./PositionView"; -import { PositionsProvider } from "@/components/PositionsContext"; -import { - createPosition, - deletePosition, - fetchPositions, - updatePosition, -} from "@/features/positions"; -import { searchParamsSchema } from "./schema"; -import { zodErrorResponse } from "@/components/FormServerHelpers"; -import { redirect } from "next/navigation"; -import { validateSearchParams } from "@/lib/searchParams/validate"; -import { getSearchParamsString } from "@/lib/searchParams/util"; - -export default async function PositionPage({ - searchParams, -}: { - searchParams: { - count?: string; - page?: string; - query?: string; - }; -}) { - const validSearchParams = validateSearchParams( - searchParamsSchema, - searchParams, - ); - - const initialPositionsData = await fetchPositions(validSearchParams); - - if (validSearchParams.page != initialPositionsData.page) { - redirect( - `/admin/positions?${getSearchParamsString({ - count: validSearchParams.count, - page: initialPositionsData.page, - query: validSearchParams.query, - })}`, - ); - } - - return ( - - { - "use server"; - - const safeData = searchParamsSchema.safeParse(data); - - if (!safeData.success) { - return zodErrorResponse(safeData.error); - } - - const positionsData = await fetchPositions({ - count: safeData.data.count, - page: safeData.data.page, - query: decodeURIComponent(safeData.data.query ?? ""), - }); - - return { ok: true, ...positionsData }; - }} - /> - - ); -} diff --git a/app/(authenticated)/(superuser)/layout.tsx b/app/(authenticated)/(superuser)/layout.tsx deleted file mode 100644 index 680c99bc..00000000 --- a/app/(authenticated)/(superuser)/layout.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { mustGetCurrentUser } from "@/lib/auth/server"; - -export default async function AuthenticatedLayout({ - children, -}: { - children: React.ReactNode; -}) { - const user = await mustGetCurrentUser(); - if (user.permissions.includes("SuperUser")) { - return <>{children}; - } else { - return

No permissions sorry

; - } -} diff --git a/app/(authenticated)/admin/layout.tsx b/app/(authenticated)/admin/layout.tsx new file mode 100644 index 00000000..fd8cfb39 --- /dev/null +++ b/app/(authenticated)/admin/layout.tsx @@ -0,0 +1,15 @@ +import ErrorPage from "@/components/ErrorPage"; +import { hasPermission } from "@/lib/auth/server"; +import { notFound } from "next/navigation"; + +export default async function AuthenticatedLayout({ + children, +}: { + children: React.ReactNode; +}) { + if (await hasPermission("Admin.Positions", "Admin.Roles", "Admin.Users")) { + return <>{children}; + } else { + return ; + } +} diff --git a/app/(authenticated)/admin/page.tsx b/app/(authenticated)/admin/page.tsx new file mode 100644 index 00000000..726b6c23 --- /dev/null +++ b/app/(authenticated)/admin/page.tsx @@ -0,0 +1,26 @@ +import { PageInfo } from "@/components/PageInfo"; +import { Button, Card, Stack } from "@mantine/core"; +import Link from "next/link"; + +export const dynamic = "force-dynamic"; + +export default function AdminPage() { + return ( + <> + + + + + + + + + + + + + + + + ); +} diff --git a/app/(authenticated)/(superuser)/admin/positions/PositionCard.tsx b/app/(authenticated)/admin/positions/PositionCard.tsx similarity index 100% rename from app/(authenticated)/(superuser)/admin/positions/PositionCard.tsx rename to app/(authenticated)/admin/positions/PositionCard.tsx diff --git a/app/(authenticated)/(superuser)/admin/positions/PositionView.tsx b/app/(authenticated)/admin/positions/PositionView.tsx similarity index 59% rename from app/(authenticated)/(superuser)/admin/positions/PositionView.tsx rename to app/(authenticated)/admin/positions/PositionView.tsx index be23b9c5..c20322b0 100644 --- a/app/(authenticated)/(superuser)/admin/positions/PositionView.tsx +++ b/app/(authenticated)/admin/positions/PositionView.tsx @@ -1,14 +1,10 @@ "use client"; -import { FormResponse } from "@/components/Form"; -import { usePositions } from "@/components/PositionsContext"; import { - ActionIcon, Button, - Card, Center, Group, - Menu, + LoadingOverlay, Modal, ScrollArea, Stack, @@ -16,16 +12,9 @@ import { } from "@mantine/core"; import { Position } from "@prisma/client"; import { usePathname, useSearchParams } from "next/navigation"; -import { - searchParamsSchema, - createPositionSchema, - deletePositionSchema, - updatePositionSchema, -} from "./schema"; +import { searchParamsSchema } from "./schema"; import { useRouter } from "next/navigation"; -import { notifications } from "@mantine/notifications"; -import { FaEdit, FaPlus } from "react-icons/fa"; -import { MdDeleteForever, MdMoreHoriz } from "react-icons/md"; +import { FaPlus } from "react-icons/fa"; import { z } from "zod"; import { useEffect, useState } from "react"; import { @@ -34,42 +23,45 @@ import { PaginationProvider, } from "@/components/Pagination"; import { useDisclosure } from "@mantine/hooks"; -import { modals } from "@mantine/modals"; import { SearchBar } from "@/components/SearchBar"; import { CreatePositionForm, UpdatePositionForm } from "./form"; import { useValidSearchParams } from "@/lib/searchParams/validate"; import { getSearchParamsString } from "@/lib/searchParams/util"; import { PositionCard } from "./PositionCard"; - -export function PositionView(props: { - createPosition: ( - data: z.infer, - ) => Promise; - updatePosition: ( - data: z.infer, - ) => Promise; - deletePosition: ( - data: z.infer, - ) => Promise; - fetchPositions: ( - data: z.infer, - ) => Promise< - FormResponse<{ positions: Position[]; page: number; total: number }> - >; -}) { +import { + createPositionAction, + deletePositionAction, + fetchPositionsAction, + TFetchPositions, + updatePositionAction, +} from "./actions"; +import { useQuery } from "@tanstack/react-query"; + +export function PositionView(props: { initialPositions: TFetchPositions }) { const pathname = usePathname(); const router = useRouter(); - const positionsContext = usePositions(); - // Get and force validate search params - const getSearchParams = useSearchParams(); + const rawSearchParams = useSearchParams(); const validSearchParams = useValidSearchParams( searchParamsSchema, - getSearchParams, + rawSearchParams, ); - const currentRange = useCurrentRange(); + const positionsQuery = useQuery({ + initialData: props.initialPositions, + queryKey: ["admin:positions", validSearchParams], + queryFn: async () => { + const res = await fetchPositionsAction(validSearchParams); + if (!res.ok) { + throw new Error("An error occurred updating roles."); + } else { + return res; + } + }, + }); + + const currentRange = useCurrentRange(positionsQuery.data); const [searchParamsState, setSearchParamsState] = useState(validSearchParams); @@ -77,11 +69,10 @@ export function PositionView(props: { useEffect(() => { const newSearchParamsString = getSearchParamsString(searchParamsState); if ( - getSearchParamsString(Object.fromEntries(getSearchParams.entries())) != + getSearchParamsString(Object.fromEntries(rawSearchParams.entries())) != newSearchParamsString ) { router.push(`${pathname}?${newSearchParamsString}`); - updatePositions(); } }, [searchParamsState]); @@ -96,18 +87,6 @@ export function PositionView(props: { Position | undefined >(); - async function updatePositions() { - const updatedPositions = await props.fetchPositions(searchParamsState); - - if (updatedPositions.ok) { - positionsContext.updateContext( - updatedPositions.positions, - updatedPositions.page, - updatedPositions.total, - ); - } - } - function updateState(state: Partial>) { setSearchParamsState({ ...searchParamsState, @@ -115,6 +94,17 @@ export function PositionView(props: { }); } + if (positionsQuery.isError) { + console.error(positionsQuery.error); + return ( + An error occurred, check the browser console for details. + ); + } + + if (!positionsQuery.isSuccess) { + return ; + } + // Wrapped in ScrollArea to avoid jerky scrolling on page change return ( @@ -128,13 +118,13 @@ export function PositionView(props: { range: currentRange, }} page={{ - current: positionsContext.page, + current: positionsQuery.data.page, set(page) { updateState({ page }); }, - total: Math.ceil(positionsContext.total / searchParamsState.count), + total: Math.ceil(positionsQuery.data.total / searchParamsState.count), }} - totalItems={positionsContext.total} + totalItems={positionsQuery.data.total} > { - updatePositions(); + positionsQuery.refetch(); closeCreateModal(); }} /> @@ -155,9 +145,9 @@ export function PositionView(props: { title={"Edit Position"} > { - updatePositions(); + positionsQuery.refetch(); closeEditModal(); setSelectedPosition(undefined); }} @@ -180,7 +170,7 @@ export function PositionView(props: { Create Position - {positionsContext.total > 0 ? ( + {positionsQuery.data.total > 0 ? ( <>
@@ -190,21 +180,21 @@ export function PositionView(props: { ) : ( No results )} - {positionsContext.positions.map((position) => { + {positionsQuery.data.positions.map((position) => { return ( { setSelectedPosition(position); openEditModal(); }} - onDeleteSuccess={updatePositions} + onDeleteSuccess={positionsQuery.refetch} position={position} /> ); })} - {positionsContext.total > 0 && ( + {positionsQuery.data.total > 0 && ( <>
@@ -218,38 +208,17 @@ export function PositionView(props: { ); } -const openDeleteModal = (props: { - onCancel: () => void; - onConfirm: () => void; - positionName: string; -}) => - modals.openConfirmModal({ - title: `Delete position "${props.positionName}"`, - centered: true, - children: ( - - Are you sure you want to delete the position "{props.positionName} - "? This action is destructive and will remove all crew sheet roles - this references. - - ), - labels: { confirm: "Delete position", cancel: "Cancel" }, - confirmProps: { color: "red" }, - onCancel: props.onCancel, - onConfirm: props.onConfirm, - }); - /** * @returns A string in the format `[start] - [end]` representing the range of currently displayed items */ -function useCurrentRange(): `${number} - ${number}` { - const positionsContext = usePositions(); +function useCurrentRange( + positionsData: TFetchPositions, +): `${number} - ${number}` { const count = Number(useSearchParams().get("count")) as number; - const endIndex = positionsContext.page * count; + const endIndex = positionsData.page * count; - const start = (positionsContext.page - 1) * count + 1; - const end = - positionsContext.total < endIndex ? positionsContext.total : endIndex; + const start = (positionsData.page - 1) * count + 1; + const end = positionsData.total < endIndex ? positionsData.total : endIndex; return `${start} - ${end}`; } diff --git a/app/(authenticated)/admin/positions/actions.ts b/app/(authenticated)/admin/positions/actions.ts new file mode 100644 index 00000000..d852fd2d --- /dev/null +++ b/app/(authenticated)/admin/positions/actions.ts @@ -0,0 +1,75 @@ +"use server"; + +import { zodErrorResponse } from "@/components/FormServerHelpers"; +import { + createPositionSchema, + deletePositionSchema, + searchParamsSchema, + updatePositionSchema, +} from "./schema"; +import { + createPosition, + deletePosition, + fetchPositions, + updatePosition, +} from "@/features/positions"; +import { Position } from "@prisma/client"; +import { FormResponse } from "@/components/Form"; + +export type TFetchPositions = { + positions: Position[]; + page: number; + total: number; +}; + +export async function fetchPositionsAction(data: unknown) { + const safeData = searchParamsSchema.safeParse(data); + + if (!safeData.success) { + return zodErrorResponse(safeData.error); + } + + const positionsData = await fetchPositions({ + count: safeData.data.count, + page: safeData.data.page, + query: decodeURIComponent(safeData.data.query ?? ""), + }); + + return { ok: true, ...positionsData }; +} + +export async function createPositionAction( + data: unknown, +): Promise> { + const safeData = createPositionSchema.safeParse(data); + + if (!safeData.success) { + return zodErrorResponse(safeData.error); + } + + return createPosition(safeData.data); +} + +export async function deletePositionAction( + data: unknown, +): Promise> { + const safeData = deletePositionSchema.safeParse(data); + + if (!safeData.success) { + return zodErrorResponse(safeData.error); + } + + return deletePosition(safeData.data); +} + +export async function updatePositionAction( + data: unknown, +): Promise> { + const safeData = updatePositionSchema.safeParse(data); + + if (!safeData.success) { + return zodErrorResponse(safeData.error); + } + + return updatePosition(safeData.data); +} diff --git a/app/(authenticated)/(superuser)/admin/positions/form.tsx b/app/(authenticated)/admin/positions/form.tsx similarity index 100% rename from app/(authenticated)/(superuser)/admin/positions/form.tsx rename to app/(authenticated)/admin/positions/form.tsx diff --git a/app/(authenticated)/admin/positions/layout.tsx b/app/(authenticated)/admin/positions/layout.tsx new file mode 100644 index 00000000..4eb73cfb --- /dev/null +++ b/app/(authenticated)/admin/positions/layout.tsx @@ -0,0 +1,14 @@ +import ErrorPage from "@/components/ErrorPage"; +import { hasPermission } from "@/lib/auth/server"; + +export default async function AuthenticatedLayout({ + children, +}: { + children: React.ReactNode; +}) { + if (await hasPermission("Admin.Positions")) { + return <>{children}; + } else { + return ; + } +} diff --git a/app/(authenticated)/admin/positions/page.tsx b/app/(authenticated)/admin/positions/page.tsx new file mode 100644 index 00000000..99fdf673 --- /dev/null +++ b/app/(authenticated)/admin/positions/page.tsx @@ -0,0 +1,43 @@ +import { PositionView } from "./PositionView"; +import { fetchPositions } from "@/features/positions"; +import { searchParamsSchema } from "./schema"; +import { redirect } from "next/navigation"; +import { validateSearchParams } from "@/lib/searchParams/validate"; +import { getSearchParamsString } from "@/lib/searchParams/util"; +import { PageInfo } from "@/components/PageInfo"; + +export const dynamic = "force-dynamic"; + +export default async function PositionPage({ + searchParams, +}: { + searchParams: { + count?: string; + page?: string; + query?: string; + }; +}) { + const validSearchParams = validateSearchParams( + searchParamsSchema, + searchParams, + ); + + const initialPositionsData = await fetchPositions(validSearchParams); + + if (validSearchParams.page != initialPositionsData.page) { + redirect( + `/admin/positions?${getSearchParamsString({ + count: validSearchParams.count, + page: initialPositionsData.page, + query: validSearchParams.query, + })}`, + ); + } + + return ( + <> + + + + ); +} diff --git a/app/(authenticated)/(superuser)/admin/positions/schema.ts b/app/(authenticated)/admin/positions/schema.ts similarity index 100% rename from app/(authenticated)/(superuser)/admin/positions/schema.ts rename to app/(authenticated)/admin/positions/schema.ts diff --git a/app/(authenticated)/admin/roles/RoleCard.tsx b/app/(authenticated)/admin/roles/RoleCard.tsx new file mode 100644 index 00000000..0de50b15 --- /dev/null +++ b/app/(authenticated)/admin/roles/RoleCard.tsx @@ -0,0 +1,111 @@ +import { RoleWithPermissions } from "@/features/people"; +import { + Card, + Stack, + Highlight, + Group, + ActionIcon, + Tooltip, + Text, +} from "@mantine/core"; +import { FaEdit } from "react-icons/fa"; +import { z } from "zod"; +import { deleteRoleSchema } from "./schema"; +import { FormResponse } from "@/components/Form"; +import { modals } from "@mantine/modals"; +import { notifications } from "@mantine/notifications"; +import { MdDeleteForever } from "react-icons/md"; + +export function RoleCard(props: { + role: RoleWithPermissions; + searchQuery: string | undefined; + editAction: () => void; + deleteAction: ( + data: z.infer, + ) => Promise; + onDeleteSuccess: () => void; +}) { + const highlightValue = props.searchQuery?.split(" ") || []; + return ( + <> + + + + {props.role.name} + {props.role.role_permissions.map((permission) => { + return ( + + {permission.permission} + + ); + })} + + + + + + + + { + openDeleteModal({ + onCancel: () => {}, + onConfirm: async () => { + const deletedRole = await props.deleteAction({ + role_id: props.role.role_id, + }); + + if (!deletedRole.ok) { + console.error(deletedRole.errors); + notifications.show({ + message: + "Unable to delete role, check the browser console for details.", + color: "red", + }); + } else { + props.onDeleteSuccess(); + notifications.show({ + message: `Successfully deleted "${props.role.name}"`, + color: "green", + }); + } + }, + roleName: props.role.name, + }); + }} + > + + + + + + + ); +} + +const openDeleteModal = (props: { + onCancel: () => void; + onConfirm: () => void; + roleName: string; +}) => + modals.openConfirmModal({ + title: `Delete role "${props.roleName}"`, + centered: true, + children: ( + + Are you sure you want to delete the role "{props.roleName} + "? This action cannot be undone and will remove this role from all + users. + + ), + labels: { confirm: "Delete role", cancel: "Cancel" }, + confirmProps: { color: "red" }, + onCancel: props.onCancel, + onConfirm: props.onConfirm, + }); diff --git a/app/(authenticated)/admin/roles/RoleView.tsx b/app/(authenticated)/admin/roles/RoleView.tsx new file mode 100644 index 00000000..d4f8de53 --- /dev/null +++ b/app/(authenticated)/admin/roles/RoleView.tsx @@ -0,0 +1,216 @@ +"use client"; + +import { + Button, + Center, + Group, + LoadingOverlay, + Modal, + ScrollArea, + Stack, + Text, +} from "@mantine/core"; +import { usePathname, useSearchParams } from "next/navigation"; +import { searchParamsSchema } from "./schema"; +import { useRouter } from "next/navigation"; +import { z } from "zod"; +import { useEffect, useState } from "react"; +import { + CountControls, + PageControls, + PaginationProvider, +} from "@/components/Pagination"; +import { useDisclosure } from "@mantine/hooks"; +import { SearchBar } from "@/components/SearchBar"; +import { useValidSearchParams } from "@/lib/searchParams/validate"; +import { getSearchParamsString } from "@/lib/searchParams/util"; +import { RoleCard } from "./RoleCard"; +import { CreateRoleForm, UpdateRoleForm } from "./form"; +import { FaPlus } from "react-icons/fa"; +import { RoleWithPermissions } from "@/features/people"; +import { + createRoleAction, + deleteRoleAction, + fetchRolesAction, + TFetchRoles, + updateRoleAction, +} from "./actions"; +import { useQuery } from "@tanstack/react-query"; + +export function RoleView(props: { initialRoles: TFetchRoles }) { + const pathname = usePathname(); + const router = useRouter(); + + // Get and force validate search params + const rawSearchParams = useSearchParams(); + const validSearchParams = useValidSearchParams( + searchParamsSchema, + rawSearchParams, + ); + + const rolesQuery = useQuery({ + initialData: props.initialRoles, + queryKey: ["admin:roles", validSearchParams], + queryFn: async () => { + const res = await fetchRolesAction(validSearchParams); + if (!res.ok) { + throw new Error("An error occurred updating roles."); + } else { + return res; + } + }, + }); + + const currentRange = useCurrentRange(rolesQuery.data); + + const [searchParamsState, setSearchParamsState] = useState(validSearchParams); + + // Push search params changes to router on state change + useEffect(() => { + const newSearchParamsString = getSearchParamsString(searchParamsState); + if ( + getSearchParamsString(Object.fromEntries(rawSearchParams.entries())) != + newSearchParamsString + ) { + router.push(`${pathname}?${newSearchParamsString}`); + } + }, [searchParamsState]); + + // States for modals + const [ + createModalOpened, + { open: openCreateModal, close: closeCreateModal }, + ] = useDisclosure(false); + const [editModalOpened, { open: openEditModal, close: closeEditModal }] = + useDisclosure(false); + const [selectedRole, setSelectedRole] = useState< + RoleWithPermissions | undefined + >(); + + function updateState(state: Partial>) { + setSearchParamsState({ + ...searchParamsState, + ...state, + }); + } + + if (!rolesQuery.isSuccess) { + return ; + } + + // Wrapped in ScrollArea to avoid jerky scrolling on page change + return ( + + + + { + rolesQuery.refetch(); + closeCreateModal(); + }} + /> + + + { + rolesQuery.refetch(); + closeEditModal(); + }} + selectedRole={selectedRole} + /> + + + { + updateState({ + query: query !== "" ? query : undefined, + }); + }} + label="Search by Role Name or Permissions" + description="Only one permission can be searched at a time." + withClear + /> + + + + {rolesQuery.data.total > 0 ? ( + <> + +
+ +
+ + ) : ( + No results + )} + {rolesQuery.data.roles.map((role) => { + return ( + { + setSelectedRole(role); + openEditModal(); + }} + onDeleteSuccess={rolesQuery.refetch} + role={role} + searchQuery={searchParamsState.query} + /> + ); + })} + {rolesQuery.data.total > 0 && ( + <> + +
+ +
+ + )} +
+
+
+ ); +} + +/** + * @returns A string in the format `[start] - [end]` representing the range of currently displayed items + */ +function useCurrentRange(rolesData: TFetchRoles): `${number} - ${number}` { + const count = Number(useSearchParams().get("count")) as number; + const endIndex = rolesData.page * count; + + const start = (rolesData.page - 1) * count + 1; + const end = rolesData.total < endIndex ? rolesData.total : endIndex; + + return `${start} - ${end}`; +} diff --git a/app/(authenticated)/admin/roles/actions.ts b/app/(authenticated)/admin/roles/actions.ts new file mode 100644 index 00000000..91b3abe9 --- /dev/null +++ b/app/(authenticated)/admin/roles/actions.ts @@ -0,0 +1,71 @@ +"use server"; + +import { zodErrorResponse } from "@/components/FormServerHelpers"; +import { + createRoleSchema, + deleteRoleSchema, + searchParamsSchema, + updateRoleSchema, +} from "./schema"; +import { fetchRoles } from "@/features/roles"; +import { + createRole, + deleteRole, + RoleWithPermissions, + updateRole, +} from "@/features/people"; +import { FormResponse } from "@/components/Form"; + +export type TFetchRoles = { + roles: RoleWithPermissions[]; + page: number; + total: number; +}; + +export async function fetchRolesAction( + data: unknown, +): Promise> { + const safeData = searchParamsSchema.safeParse(data); + + if (!safeData.success) { + return zodErrorResponse(safeData.error); + } + + const rolesData = await fetchRoles({ + count: safeData.data.count, + page: safeData.data.page, + query: decodeURIComponent(safeData.data.query ?? ""), + }); + + return { ok: true, ...rolesData }; +} + +export async function createRoleAction(data: unknown) { + const safeData = createRoleSchema.safeParse(data); + + if (!safeData.success) { + return zodErrorResponse(safeData.error); + } + + return createRole(safeData.data); +} + +export async function updateRoleAction(data: unknown) { + const safeData = updateRoleSchema.safeParse(data); + + if (!safeData.success) { + return zodErrorResponse(safeData.error); + } + + return updateRole(safeData.data); +} + +export async function deleteRoleAction(data: unknown) { + const safeData = deleteRoleSchema.safeParse(data); + + if (!safeData.success) { + return zodErrorResponse(safeData.error); + } + + return deleteRole(safeData.data); +} diff --git a/app/(authenticated)/admin/roles/form.tsx b/app/(authenticated)/admin/roles/form.tsx new file mode 100644 index 00000000..42ec8303 --- /dev/null +++ b/app/(authenticated)/admin/roles/form.tsx @@ -0,0 +1,81 @@ +import Form, { FormResponse } from "@/components/Form"; +import { createRoleSchema, updateRoleSchema } from "./schema"; +import { z } from "zod"; +import { + PermissionSelectField, + TextAreaField, + TextField, +} from "@/components/FormFields"; +import { Permission } from "@/lib/auth/permissions"; +import { Space } from "@mantine/core"; +import { RoleWithPermissions } from "@/features/people"; + +export function CreateRoleForm(props: { + action: (data: z.infer) => Promise; + onSuccess: () => void; + initialValues?: z.infer; +}) { + return ( +
+ + + + + + ); +} + +export function UpdateRoleForm(props: { + action: (data: z.infer) => Promise; + onSuccess: () => void; + selectedRole?: RoleWithPermissions; +}) { + if (!props.selectedRole) { + return <>No Role Selected.; + } + + const formSafeRole = { + ...props.selectedRole, + role_permissions: undefined, + permissions: props.selectedRole.role_permissions.map( + (v) => v.permission as Permission, + ), + }; + + return ( +
{ + if (!props.selectedRole) { + throw new Error("No selected role"); + } + return props.action({ + role_id: props.selectedRole.role_id, + ...data, + }); + }} + onSuccess={props.onSuccess} + schema={updateRoleSchema.omit({ role_id: true })} + initialValues={formSafeRole} + > + + + + + + ); +} diff --git a/app/(authenticated)/admin/roles/layout.tsx b/app/(authenticated)/admin/roles/layout.tsx new file mode 100644 index 00000000..4eb73cfb --- /dev/null +++ b/app/(authenticated)/admin/roles/layout.tsx @@ -0,0 +1,14 @@ +import ErrorPage from "@/components/ErrorPage"; +import { hasPermission } from "@/lib/auth/server"; + +export default async function AuthenticatedLayout({ + children, +}: { + children: React.ReactNode; +}) { + if (await hasPermission("Admin.Positions")) { + return <>{children}; + } else { + return ; + } +} diff --git a/app/(authenticated)/admin/roles/page.tsx b/app/(authenticated)/admin/roles/page.tsx new file mode 100644 index 00000000..7482f306 --- /dev/null +++ b/app/(authenticated)/admin/roles/page.tsx @@ -0,0 +1,53 @@ +import { RoleView } from "./RoleView"; +import { searchParamsSchema } from "./schema"; +import { redirect } from "next/navigation"; +import { validateSearchParams } from "@/lib/searchParams/validate"; +import { getSearchParamsString } from "@/lib/searchParams/util"; +import { fetchRolesAction } from "./actions"; +import { Stack, Text } from "@mantine/core"; +import { PageInfo } from "@/components/PageInfo"; + +export const dynamic = "force-dynamic"; + +export default async function PositionPage({ + searchParams, +}: { + searchParams: { + count?: string; + page?: string; + query?: string; + }; +}) { + const validSearchParams = validateSearchParams( + searchParamsSchema, + searchParams, + ); + + const initialRolesData = await fetchRolesAction(validSearchParams); + + if (!initialRolesData.ok) { + return ( + + An error occurred + {initialRolesData.errors["root"]} + + ); + } + + if (validSearchParams.page != initialRolesData.page) { + redirect( + `/admin/roles?${getSearchParamsString({ + count: validSearchParams.count, + page: initialRolesData.page, + query: validSearchParams.query, + })}`, + ); + } + + return ( + <> + + + + ); +} diff --git a/app/(authenticated)/admin/roles/schema.ts b/app/(authenticated)/admin/roles/schema.ts new file mode 100644 index 00000000..9ab7b01c --- /dev/null +++ b/app/(authenticated)/admin/roles/schema.ts @@ -0,0 +1,29 @@ +import { PermissionEnum } from "@/lib/auth/permissions"; +import { z } from "zod"; + +export const searchParamsSchema = z.object({ + count: z + .preprocess((val) => (val ? val : undefined), z.coerce.number()) + .default(10), + page: z + .preprocess((val) => (val ? val : undefined), z.coerce.number()) + .default(1), + query: z.string().optional(), +}); + +export const createRoleSchema = z.object({ + name: z.string().min(3), + description: z.string().optional(), + permissions: z.array(PermissionEnum), +}); + +export const updateRoleSchema = z.object({ + role_id: z.number(), + name: z.string().min(3), + description: z.string().optional(), + permissions: z.array(PermissionEnum), +}); + +export const deleteRoleSchema = z.object({ + role_id: z.number(), +}); diff --git a/app/(authenticated)/admin/users/UserCard.tsx b/app/(authenticated)/admin/users/UserCard.tsx new file mode 100644 index 00000000..f3ef7831 --- /dev/null +++ b/app/(authenticated)/admin/users/UserCard.tsx @@ -0,0 +1,90 @@ +import GoogleIcon from "@/components/icons/GoogleIcon"; +import SlackIcon from "@/components/icons/SlackIcon"; +import { + Card, + Group, + Stack, + ActionIcon, + Avatar, + Highlight, + Text, + Tooltip, +} from "@mantine/core"; +import Link from "next/link"; +import { FaEye } from "react-icons/fa"; +import { UserWithIdentitiesBasicRoles } from "./actions"; + +export function UserCard(props: { + user: UserWithIdentitiesBasicRoles; + searchQuery: string | undefined; +}) { + return ( + <> + + + + + + + + + {`${props.user.first_name} ${ + props.user.nickname && '"' + props.user.nickname + '" ' + }${props.user.last_name}`} + + + {props.user.email} + + + + {props.user.identities.map((identity) => { + switch (identity.provider) { + case "google": + return ( + + + + ); + + case "slack": + return ( + + + + ); + + default: + break; + } + + return <>; + })} + + + + + + + + + + {props.user.roles.length > 0 && ( + + {props.user.roles.map((role) => { + return ( + + {role.name} + + ); + })} + + )} + + + + ); +} diff --git a/app/(authenticated)/admin/users/UserView.tsx b/app/(authenticated)/admin/users/UserView.tsx new file mode 100644 index 00000000..7a690bcf --- /dev/null +++ b/app/(authenticated)/admin/users/UserView.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { + Center, + Group, + LoadingOverlay, + ScrollArea, + Stack, + Text, +} from "@mantine/core"; +import { usePathname, useSearchParams } from "next/navigation"; +import { searchParamsSchema } from "./schema"; +import { useRouter } from "next/navigation"; +import { z } from "zod"; +import { useEffect, useState } from "react"; +import { + CountControls, + PageControls, + PaginationProvider, +} from "@/components/Pagination"; +import { SearchBar } from "@/components/SearchBar"; +import { useValidSearchParams } from "@/lib/searchParams/validate"; +import { getSearchParamsString } from "@/lib/searchParams/util"; +import { UserCard } from "./UserCard"; +import { useQuery } from "@tanstack/react-query"; +import { fetchUsersAction, TFetchUsers } from "./actions"; + +export function UserView(props: { initialUsers: TFetchUsers }) { + const pathname = usePathname(); + const router = useRouter(); + + // Get and force validate search params + const rawSearchParams = useSearchParams(); + const validSearchParams = useValidSearchParams( + searchParamsSchema, + rawSearchParams, + ); + + const usersQuery = useQuery({ + initialData: props.initialUsers, + queryKey: ["admin:users", validSearchParams], + queryFn: async () => { + const res = await fetchUsersAction(validSearchParams); + if (!res.ok) { + throw new Error("An error occurred updating roles." + res.errors); + } else { + return res; + } + }, + }); + + const currentRange = useCurrentRange(usersQuery.data); + + const [searchParamsState, setSearchParamsState] = useState(validSearchParams); + + // Push search params changes to router on state change + useEffect(() => { + const newSearchParamsString = getSearchParamsString(searchParamsState); + if ( + getSearchParamsString(Object.fromEntries(rawSearchParams.entries())) != + newSearchParamsString + ) { + router.push(`${pathname}?${newSearchParamsString}`); + } + }, [searchParamsState]); + + function updateState(state: Partial>) { + setSearchParamsState({ + ...searchParamsState, + ...state, + }); + } + + if (!usersQuery.isSuccess) { + return ; + } + + // Wrapped in ScrollArea to avoid jerky scrolling on page change + return ( + + + + { + updateState({ + query: query !== "" ? query : undefined, + }); + }} + label="Search by Name or Email" + description="Search must start with the first, last, or nick name." + withClear + /> + + {usersQuery.data.total > 0 ? ( + <> + +
+ +
+ + ) : ( + No results + )} + {usersQuery.data.users.map((user) => { + return ( + + ); + })} + {usersQuery.data.total > 0 && ( + <> + +
+ +
+ + )} +
+
+
+ ); +} + +/** + * @returns A string in the format `[start] - [end]` representing the range of currently displayed items + */ +function useCurrentRange(usersData: TFetchUsers): `${number} - ${number}` { + const count = Number(useSearchParams().get("count")) as number; + const endIndex = usersData.page * count; + + const start = (usersData.page - 1) * count + 1; + const end = usersData.total < endIndex ? usersData.total : endIndex; + + return `${start} - ${end}`; +} diff --git a/app/(authenticated)/admin/users/[userID]/AdminUserView.tsx b/app/(authenticated)/admin/users/[userID]/AdminUserView.tsx new file mode 100644 index 00000000..bb464832 --- /dev/null +++ b/app/(authenticated)/admin/users/[userID]/AdminUserView.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { FormResponse } from "@/components/Form"; +import { getUserName } from "@/components/UserHelpers"; +import { UserWithIdentitiesRoles } from "@/features/people"; +import { + Stack, + Card, + Group, + Avatar, + Space, + Text, + Modal, + Button, + ActionIcon, + Tooltip, +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { Role } from "@prisma/client"; +import { Suspense } from "react"; +import { z } from "zod"; +import { GiveUserRoleForm } from "./GiveUserRoleForm"; +import { useRouter } from "next/navigation"; +import { FaEdit, FaMinus } from "react-icons/fa"; +import { modals } from "@mantine/modals"; +import { notifications } from "@mantine/notifications"; +import { editUserSchema } from "./schema"; +import { EditUserForm } from "./EditUserForm"; +import { giveUserRoleSchema } from "@/features/people/schema"; + +export function AdminUserView(props: { + user: UserWithIdentitiesRoles; + giveUserRole: ( + data: z.infer, + ) => Promise; + removeUserRole: ( + data: z.infer, + ) => Promise; + editUserAction: ( + data: z.infer, + ) => Promise; + userAbsentRoles: Promise; +}) { + const [ + addRoleModalOpened, + { open: openAddRoleModal, close: closeAddRoleModal }, + ] = useDisclosure(false); + + const [editModalOpened, { open: openEditModal, close: closeEditModal }] = + useDisclosure(false); + + const router = useRouter(); + + return ( + <> + + + { + const response = await props.giveUserRole({ + user_id: props.user.user_id, + role_id: role_id, + }); + if (response.ok) { + closeAddRoleModal(); + router.refresh(); + return response; + } else { + return response; + } + }} + /> + + + + { + closeEditModal(); + notifications.show({ + color: "green", + message: `Successfully updated user!`, + }); + router.refresh(); + }} + /> + + + + + + + {getUserName(props.user)} + + {props.user.email} + + + + + + + + + + + {props.user.roles.length > 0 ? ( + <> + + User Roles: + + + + + {props.user.roles.map((role) => { + return ( + + + + {role.name} + {role.role_permissions.map((permission) => { + return ( + + {permission.permission} + + ); + })} + + { + confirmRoleDelete({ + role, + onConfirm: async () => { + const response = await props.removeUserRole({ + user_id: props.user.user_id, + role_id: role.role_id, + }); + + if (response.ok) { + notifications.show({ + color: "green", + message: `Successfully removed user from ${role.name} role.`, + }); + router.refresh(); + } else { + console.error(response.errors); + notifications.show({ + color: "red", + message: `Failed to remove user from ${role.name} role, check the browser console for details.`, + }); + } + }, + onCancel() {}, + }); + }} + > + + + + + ); + })} + + + ) : ( + <> + + This user has no roles. + + + + )} + + + + ); +} + +function confirmRoleDelete({ + role, + onConfirm, + onCancel, +}: { + role: Role; + onConfirm: () => void; + onCancel: () => void; +}): void { + modals.openConfirmModal({ + title: "Remove role from user?", + children: This will remove the role "{role.name}", + labels: { confirm: "Confirm", cancel: "Cancel" }, + onCancel, + onConfirm, + confirmProps: { color: "red" }, + }); +} diff --git a/app/(authenticated)/admin/users/[userID]/EditUserForm.tsx b/app/(authenticated)/admin/users/[userID]/EditUserForm.tsx new file mode 100644 index 00000000..68da7287 --- /dev/null +++ b/app/(authenticated)/admin/users/[userID]/EditUserForm.tsx @@ -0,0 +1,29 @@ +import Form, { FormResponse } from "@/components/Form"; +import { User } from "@prisma/client"; +import { editUserSchema } from "./schema"; +import { TextField } from "@/components/FormFields"; +import { z } from "zod"; + +export function EditUserForm(props: { + user: User; + action: (data: z.infer) => Promise; + onSuccess: () => void; +}) { + return ( +
+ + + + + ); +} diff --git a/app/(authenticated)/admin/users/[userID]/GiveUserRoleForm.tsx b/app/(authenticated)/admin/users/[userID]/GiveUserRoleForm.tsx new file mode 100644 index 00000000..c3600a18 --- /dev/null +++ b/app/(authenticated)/admin/users/[userID]/GiveUserRoleForm.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { FormResponse } from "@/components/Form"; +import { + ActionIcon, + Card, + Group, + LoadingOverlay, + Stack, + Text, +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { notifications } from "@mantine/notifications"; +import { Role } from "@prisma/client"; +import { use } from "react"; +import { FaPlus } from "react-icons/fa"; + +export function GiveUserRoleForm(props: { + onGiveRole: (role_id: number) => Promise; + roles: Promise; +}) { + const roles = use(props.roles); + + const [isLoading, { open: setLoading, close: unsetLoading }] = + useDisclosure(false); + + return ( + <> + + + {roles.length != 0 ? ( + roles.map((role) => { + return ( + + + + {role.name} + {role.description && ( + + {role.description} + + )} + + { + setLoading(); + const response = await props.onGiveRole(role.role_id); + if (response.ok) { + unsetLoading(); + notifications.show({ + color: "green", + message: `Successfully gave user ${role.name} role.`, + }); + } else { + unsetLoading(); + notifications.show({ + color: "red", + message: `Unable to give user ${role.name} role: ${response.errors}`, + }); + } + }} + disabled={isLoading} + > + + + + + ); + }) + ) : ( + + + No more roles available + + + They caught 'em all + + + )} + + + ); +} diff --git a/app/(authenticated)/admin/users/[userID]/page.tsx b/app/(authenticated)/admin/users/[userID]/page.tsx new file mode 100644 index 00000000..15641ab4 --- /dev/null +++ b/app/(authenticated)/admin/users/[userID]/page.tsx @@ -0,0 +1,46 @@ +import { + editUserAdmin, + fetchUserForAdmin, + getUserAbsentRoles, + giveUserRole, + removeUserRole, +} from "@/features/people"; +import { z } from "zod"; +import { AdminUserView } from "./AdminUserView"; +import { PageInfo } from "@/components/PageInfo"; +import { getUserName } from "@/components/UserHelpers"; + +export default async function SingleUserPage({ + params, +}: { + params: { userID: string }; +}) { + const userIDParse = z + .preprocess((val) => (val ? val : undefined), z.coerce.number()) + .safeParse(params.userID); + + if (!userIDParse.success) { + return <>Invalid User ID; + } + + const user = await fetchUserForAdmin({ user_id: userIDParse.data }); + + if (!user) { + return <>Invalid User ID; + } + + const userAbsentRoles = getUserAbsentRoles({ user_id: user.user_id }); + + return ( + <> + + + + ); +} diff --git a/app/(authenticated)/admin/users/[userID]/schema.ts b/app/(authenticated)/admin/users/[userID]/schema.ts new file mode 100644 index 00000000..b1af79fb --- /dev/null +++ b/app/(authenticated)/admin/users/[userID]/schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const editUserSchema = z.object({ + user_id: z.number(), + first_name: z.string(), + nickname: z.string().optional(), + last_name: z.string(), +}); diff --git a/app/(authenticated)/admin/users/actions.ts b/app/(authenticated)/admin/users/actions.ts new file mode 100644 index 00000000..387c0d7d --- /dev/null +++ b/app/(authenticated)/admin/users/actions.ts @@ -0,0 +1,36 @@ +"use server"; + +import { zodErrorResponse } from "@/components/FormServerHelpers"; +import { searchParamsSchema } from "./schema"; +import { fetchUsers } from "@/features/people"; +import { FormResponse } from "@/components/Form"; +import { UserWithIdentities } from "@/lib/auth/server"; +import { Role } from "@prisma/client"; + +export type UserWithIdentitiesBasicRoles = UserWithIdentities & { + roles: Role[]; +}; + +export type TFetchUsers = { + users: UserWithIdentitiesBasicRoles[]; + page: number; + total: number; +}; + +export async function fetchUsersAction( + data: unknown, +): Promise> { + const safeData = searchParamsSchema.safeParse(data); + + if (!safeData.success) { + return zodErrorResponse(safeData.error); + } + + const usersData = await fetchUsers({ + count: safeData.data.count, + page: safeData.data.page, + query: decodeURIComponent(safeData.data.query ?? ""), + }); + + return { ok: true, ...usersData }; +} diff --git a/app/(authenticated)/admin/users/layout.tsx b/app/(authenticated)/admin/users/layout.tsx new file mode 100644 index 00000000..4eb73cfb --- /dev/null +++ b/app/(authenticated)/admin/users/layout.tsx @@ -0,0 +1,14 @@ +import ErrorPage from "@/components/ErrorPage"; +import { hasPermission } from "@/lib/auth/server"; + +export default async function AuthenticatedLayout({ + children, +}: { + children: React.ReactNode; +}) { + if (await hasPermission("Admin.Positions")) { + return <>{children}; + } else { + return ; + } +} diff --git a/app/(authenticated)/admin/users/page.tsx b/app/(authenticated)/admin/users/page.tsx new file mode 100644 index 00000000..c7f1794e --- /dev/null +++ b/app/(authenticated)/admin/users/page.tsx @@ -0,0 +1,41 @@ +import { UserView } from "./UserView"; +import { fetchUsers } from "@/features/people"; +import { searchParamsSchema } from "./schema"; +import { redirect } from "next/navigation"; +import { validateSearchParams } from "@/lib/searchParams/validate"; +import { getSearchParamsString } from "@/lib/searchParams/util"; +import { PageInfo } from "@/components/PageInfo"; + +export default async function PositionPage({ + searchParams, +}: { + searchParams: { + count?: string; + page?: string; + query?: string; + }; +}) { + const validSearchParams = validateSearchParams( + searchParamsSchema, + searchParams, + ); + + const initialUsersData = await fetchUsers(validSearchParams); + + if (validSearchParams.page != initialUsersData.page) { + redirect( + `/admin/users?${getSearchParamsString({ + count: validSearchParams.count, + page: initialUsersData.page, + query: validSearchParams.query, + })}`, + ); + } + + return ( + <> + + + + ); +} diff --git a/app/(authenticated)/admin/users/schema.ts b/app/(authenticated)/admin/users/schema.ts new file mode 100644 index 00000000..2448a979 --- /dev/null +++ b/app/(authenticated)/admin/users/schema.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const searchParamsSchema = z.object({ + count: z + .preprocess((val) => (val ? val : undefined), z.coerce.number()) + .default(10), + page: z + .preprocess((val) => (val ? val : undefined), z.coerce.number()) + .default(1), + query: z.string().optional(), +}); diff --git a/components/YSTVCalendar.css b/app/(authenticated)/calendar/YSTVCalendar.css similarity index 100% rename from components/YSTVCalendar.css rename to app/(authenticated)/calendar/YSTVCalendar.css diff --git a/app/(authenticated)/calendar/YSTVCalendar.tsx b/app/(authenticated)/calendar/YSTVCalendar.tsx new file mode 100644 index 00000000..5bc2f15d --- /dev/null +++ b/app/(authenticated)/calendar/YSTVCalendar.tsx @@ -0,0 +1,400 @@ +"use client"; +import FullCalendar from "@fullcalendar/react"; +import dayGridPlugin from "@fullcalendar/daygrid"; +import timeGridPlugin from "@fullcalendar/timegrid"; +import listPlugin from "@fullcalendar/list"; +import { EventInput, formatDate } from "@fullcalendar/core"; +import { useRouter } from "next/navigation"; +import { useMediaQuery } from "@mantine/hooks"; +import { + CalendarType, + academicYears, + getNextPeriod, + Holiday, +} from "uoy-week-calendar/dist/calendar"; +import "./YSTVCalendar.css"; +import dayjs from "dayjs"; +import weekOfYear from "dayjs/plugin/weekOfYear"; +import { + ActionIcon, + Menu, + Select, + Loader, + Box, + LoadingOverlay, +} from "@mantine/core"; +import { useEffect, useRef, useState } from "react"; +import * as Sentry from "@sentry/nextjs"; +import { TbCheck, TbFilter } from "react-icons/tb"; +import findLast from "core-js-pure/stable/array/find-last"; +import { useUserPreferences } from "../../../components/UserContext"; +import { z } from "zod"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { fetchEvents } from "./actions"; +import { calendarEventsQueryKey } from "./helpers"; +import { EventColours } from "@/features/calendar/types"; +import { EventType } from "@/features/calendar/types"; + +dayjs.extend(weekOfYear); + +let didLogAcademicYearError = false; + +function getUoYWeekName(date: Date) { + if (!Array.isArray(academicYears)) { + // Something has gone badly wrong (https://linear.app/ystv/issue/WEB-100/typeerror-cacademicyearsfindlast-is-not-a-function-in) + if (!didLogAcademicYearError) { + Sentry.captureException(new Error("Failed to load academicYears"), { + extra: { + academicYears, + }, + }); + didLogAcademicYearError = true; + } + return "Week " + dayjs(date).week(); + } + const academicYear = findLast( + academicYears, + (x) => x.periods[0].startDate.getTime() <= date.getTime(), + ); + if (!academicYear) { + return "Week " + dayjs(date).week(); + } + let period = academicYear.periods[0]; + let nextPeriod = getNextPeriod(period, academicYear); + while (nextPeriod.startDate.getTime() <= date.getTime()) { + period = nextPeriod; + nextPeriod = getNextPeriod(period, academicYear); + } + + if (period instanceof Holiday) { + return period.name + " Vacation"; + } + + const name = period + .getWeekName(date, CalendarType.UNDERGRADUATE) + .replace("Teaching", ""); + // HACK pending upstream changes + if (name.includes("(")) { + return name.replace(/^.*\((.+)\)$/, "$1"); + } + return name; +} + +const HistoryStateSchema = z.object({ + year: z.number().optional(), + month: z.number().optional(), + day: z.number().optional(), + view: z.string().optional(), + filter: z.enum(["all", "mine", "vacant"]).optional(), +}); + +const getEventColor = (evt: Event): string => { + if (evt.is_cancelled) return "#B00020"; + if (evt.is_tentative) return "#8b8b8b"; + return EventColours[evt.event_type as EventType] || "#FFC0CB"; +}; + +export default function YSTVCalendar() { + const currentDate = new Date(); + + const router = useRouter(); + const prefs = useUserPreferences(); + + const isMobileView = useMediaQuery("(max-width: 650px)", undefined, { + getInitialValueInEffect: true, + }); + + let historyState; + try { + historyState = HistoryStateSchema.parse(history.state); + } catch (e) { + historyState = {}; + } + + const [state, _setState] = useState({ + year: currentDate.getFullYear(), + month: currentDate.getMonth(), + day: currentDate.getDate(), + view: undefined, + filter: "all", + ...historyState, + } satisfies z.infer); + + useEffect(() => { + history.replaceState(state, ""); + }, [state]); + + function setState(data: Partial) { + _setState((prev) => ({ ...prev, ...data })); + } + + if (state.view === undefined && isMobileView !== undefined) { + setState({ + view: isMobileView ? "dayGridWeek" : "dayGridMonth", + }); + } + + const selectedFilter = state.filter; + + const { + data: events, + isFetching, + isPlaceholderData, + } = useQuery({ + queryKey: calendarEventsQueryKey({ + year: state.year, + month: state.month, + filter: selectedFilter, + }), + queryFn: (args) => fetchEvents(args.queryKey[1]), + staleTime: 60_000, + placeholderData: keepPreviousData, + }); + + const calendarRef = useRef(null); + + const viewsList = [ + { value: "dayGridMonth", label: "Month" }, + { value: "dayGridWeek", label: "Week" }, + { value: "listMonth", label: "List" }, + { value: "timeGridDay", label: "Day" }, + ]; + + if (isMobileView === undefined || (isFetching && !isPlaceholderData)) + return ( +
+ +
+ ); + + const selectedDate = new Date(state.year, state.month, state.day); + + return ( + <> +
+ + + + + + + + Filter Events + , + disabled: true, + })} + onClick={() => setState({ filter: "all" })} + > + All + + , + disabled: true, + })} + onClick={() => setState({ filter: "vacant" })} + > + Vacant + + , + disabled: true, + })} + onClick={() => setState({ filter: "mine" })} + > + My + + + + {isMobileView && calendarRef.current && ( + ); } + +export function PermissionSelectField(props: { + name: string; + defaultValue?: string[]; + label: string; + required?: boolean; +}) { + const controller = useController({ + name: props.name, + defaultValue: props.defaultValue, + }); + + const [filter, setFilter] = useState(""); + + return ( + <> + {props.label} + + setFilter(e.currentTarget.value)} + rightSection={ + setFilter("")} + disabled={filter === ""} + > + + + } + /> + + { + controller.field.onChange(value); + }} + > + + {(Object.keys(PermissionEnum.Values) as Permission[]) + .filter( + (v) => + v.toLowerCase().includes(filter.toLowerCase()) || + (controller.field.value as Permission[]).includes(v), + ) + .map((key) => ( + + {key} + + ))} + + + + + ); +} diff --git a/components/LoginPrompt.tsx b/components/LoginPrompt.tsx index bc6cbd81..3ebcea44 100644 --- a/components/LoginPrompt.tsx +++ b/components/LoginPrompt.tsx @@ -14,7 +14,7 @@ export function LoginPrompt() { useEffect(() => { doLoginRedirect(); - }, []); + }); setTimeout(doLoginRedirect, 3000); diff --git a/components/Nav.tsx b/components/Nav.tsx index 3f588750..04f947d2 100644 --- a/components/Nav.tsx +++ b/components/Nav.tsx @@ -1,12 +1,26 @@ "use client"; -import { AppShell, Group, rem } from "@mantine/core"; -import { useHeadroom } from "@mantine/hooks"; +import { + Anchor, + AppShell, + Box, + Burger, + Group, + NavLink, + rem, + Text, +} from "@mantine/core"; +import { useDisclosure, useHeadroom } from "@mantine/hooks"; import Image from "next/image"; import Link from "next/link"; import Logo from "@/app/_assets/logo.png"; import { UserMenu } from "@/components/UserMenu"; import styles from "@/styles/Nav.module.css"; import YSTVBreadcrumbs from "@/components/Breadcrumbs"; +import { FeedbackPrompt } from "./FeedbackPrompt"; +import { LuCalendar, LuCog, LuNewspaper, LuUser } from "react-icons/lu"; +import { usePathname } from "next/navigation"; +import path from "path"; +import Sidebar from "@/components/Sidebar"; interface NavProps { children: React.ReactNode; @@ -14,34 +28,95 @@ interface NavProps { } export default function Nav({ children, user }: NavProps) { - const pinned = useHeadroom({ fixedAt: 100 }); + const headerPinned = { + /*useHeadroom({ fixedAt: 100 });}*/ + }; + const footerPinned = useHeadroom({ fixedAt: 100 }); + const [mobileOpened, { toggle: toggleMobile }] = useDisclosure(); + const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true); + const pathname = usePathname(); + return ( - - - - - - - -
- -
-
-
+ <> + + + + {/* Left side with burger menus */} +
+ + +
+ + {/* Centered logo with negative margin */} +
+ + Logo + +
+ + {/* Optional Breadcrumbs, visible on larger screens */} + + + +
+
- - {children} - -
+ + + + + {children} + + {/* +
+ + Calendar version {process.env.NEXT_PUBLIC_RELEASE}. Built and + maintained by the YSTV Computing Team. + +
+
*/} +
+ ); } diff --git a/components/PageInfo.tsx b/components/PageInfo.tsx new file mode 100644 index 00000000..6c73ee45 --- /dev/null +++ b/components/PageInfo.tsx @@ -0,0 +1,7 @@ +"use client"; + +export function PageInfo(props: { title?: string }) { + return ( + {`${props.title ? props.title + " | " : ""}YSTV Calendar`} + ); +} diff --git a/components/PositionsContext.tsx b/components/PositionsContext.tsx deleted file mode 100644 index 8d9b3973..00000000 --- a/components/PositionsContext.tsx +++ /dev/null @@ -1,65 +0,0 @@ -"use client"; - -import { Position } from "@prisma/client"; -import React, { createContext, useContext, useState } from "react"; - -type TPositionsContext = { - positions: Position[]; - setPositions: (positions: Position[]) => void; - page: number; - setPage: (page: number) => void; - total: number; - setTotal: (total: number) => void; - state: { positions: Position[]; page: number; total: number }; - updateContext: (positions: Position[], page: number, total: number) => void; -}; - -const PositionsContext = createContext( - null as unknown as TPositionsContext, -); - -export function PositionsProvider(props: { - children: React.ReactNode; - positions?: Position[]; - page: number; - total: number; -}) { - const [state, setState] = useState({ - positions: props.positions ?? [], - page: props.page, - total: props.total, - }); - - function updateContext(positions: Position[], page: number, total: number) { - setState({ - positions, - page, - total, - }); - } - - return ( - { - setState({ ...state, positions }); - }, - page: state.page, - setPage: (page) => { - setState({ ...state, page }); - }, - total: state.total, - setTotal: (total) => { - setState({ ...state, total }); - }, - state, - updateContext, - }} - > - {props.children} - - ); -} - -export const usePositions = () => useContext(PositionsContext); diff --git a/components/SearchBar.tsx b/components/SearchBar.tsx index ec4768d6..34927cac 100644 --- a/components/SearchBar.tsx +++ b/components/SearchBar.tsx @@ -7,6 +7,7 @@ export function SearchBar(props: { onChange: (query: string | undefined) => void; delay?: number; label?: string; + description?: string; withClear?: boolean; }) { const [searchQueryState, setSearchQueryState] = useState( @@ -18,13 +19,14 @@ export function SearchBar(props: { props.onChange(searchQueryState); }, props.delay ?? 500); return () => clearTimeout(delayInputTimeoutId); - }, [searchQueryState, props.delay]); + }, [searchQueryState, props]); const isSearchEmpty = searchQueryState == ""; return ( diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx new file mode 100644 index 00000000..ac98a2ab --- /dev/null +++ b/components/Sidebar.tsx @@ -0,0 +1,174 @@ +import { useEffect, useState } from "react"; +import { + NavLink, + Paper, + Text, + SegmentedControl, + Center, + VisuallyHidden, +} from "@mantine/core"; +import { + LuCalendar, + LuCog, + LuNewspaper, + LuUser, + LuLaptop, + LuMoon, + LuSun, + LuBookMarked, + LuMessageSquare, +} from "react-icons/lu"; +import { usePathname, useRouter } from "next/navigation"; +import Image from "next/image"; +import styles from "@/styles/Nav.module.css"; +import { getUserName } from "@/components/UserHelpers"; +import { useCurrentUser } from "@/components/UserContext"; // Import useCurrentUser +import { PermissionGate } from "@/components/UserContext"; +import { useMantineColorScheme } from "@mantine/core"; + +// Define a type for permissions +type Permission = + | "SuperUser" + | "PUBLIC" + | "MEMBER" + | "Watch.Admin" + | "Calendar.Admin" + | "Calendar.Show.Admin" + | "Calendar.Show.Creator" + | "Calendar.Meeting.Admin" + | "Calendar.Meeting.Creator" + | "ManageQuotes"; + +export default function Sidebar() { + const user = useCurrentUser(); // Get user from context + const userName = getUserName(user); + const pathname = usePathname(); + const router = useRouter(); + const { setColorScheme, colorScheme } = useMantineColorScheme(); + + const navLinks: { + href: string; + label: string; + icon: JSX.Element; + permission?: Permission; + }[] = [ + { href: "/calendar", label: "Calendar", icon: }, + { + href: "/quotes", + label: "Quotes Board", + icon: , + permission: "ManageQuotes", + }, + { + href: "/admin", + label: "Admin", + icon: , + permission: "SuperUser", + }, + { href: "/news", label: "News", icon: }, + { href: "/feedback", label: "Feedback", icon: }, + ]; + + const colors = [ + "red", + "orange", + "yellow", + "green", + "blue", + "indigo", + "violet", + ]; // Colors for the nav links + + const userPermissions = user.permissions || []; // Example user permissions, replace with actual logic + + const filteredNavLinks = navLinks.filter( + (link) => + !link.permission || + userPermissions.includes(link.permission) || + userPermissions.includes("SuperUser"), + ); + + return ( +
+
+ {filteredNavLinks.map((link, index) => ( + + {link.href === "/admin" && ( + + } + variant={ + pathname.includes("admin/positions") ? "filled" : "light" + } + className="rounded-lg" + mt="md" + active + color={colors[index]} + /> + } + variant={ + pathname.includes("admin/roles") ? "filled" : "light" + } + className="rounded-lg" + mt="md" + active + color={colors[index]} + /> + } + variant={ + pathname.includes("admin/users") ? "filled" : "light" + } + className="rounded-lg" + mt="md" + active + color={colors[index]} + /> + + )} + + ))} +
+ router.push("/user/me")} + > +
+ + + {userName} + +
+
+
+ ); +} diff --git a/components/SignoutButton/actions.ts b/components/SignoutButton/actions.ts index 33983789..016cf0c5 100644 --- a/components/SignoutButton/actions.ts +++ b/components/SignoutButton/actions.ts @@ -1,11 +1,13 @@ "use server"; +import { wrapServerAction } from "@/lib/actions"; +import { COOKIE_NAME } from "@/lib/auth/core"; import { env } from "@/lib/env"; import { cookies } from "next/headers"; -export async function signOut() { - cookies().set("ystv-calendar-session", "", { +export const signOut = wrapServerAction("signOut", async function signOut() { + cookies().set(COOKIE_NAME, "", { maxAge: 0, domain: env.COOKIE_DOMAIN, }); -} +}); diff --git a/components/WebcamView.tsx b/components/WebcamView.tsx new file mode 100644 index 00000000..c37361c9 --- /dev/null +++ b/components/WebcamView.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import MuxVideo from "@mux/mux-video-react"; +import { Box, LoadingOverlay } from "@mantine/core"; + +export function WebcamView(props: { + webcamUrl: string; + width?: string | number; + parentHeight?: number; +}) { + const ref = useRef(null); + + const [videoReady, setVideoReady] = useState(false); + + useEffect(() => { + // Copy the element ref so that we can still reference it in the + // cleanup callback. + const el = ref.current; + if (!el) { + return; + } + function isReady() { + setVideoReady(true); + } + function isNotReady() { + setVideoReady(false); + } + el.addEventListener("canplay", isReady); + for (const evt of ["stalled", "waiting", "error"]) { + el.addEventListener(evt, isNotReady); + } + return () => { + el.removeEventListener("canplay", isReady); + for (const evt of ["stalled", "waiting", "error"]) { + el.removeEventListener(evt, isNotReady); + } + }; + // Since the key of the video element is also webcamUrl, this effect + // will clean up and re-run whenever the webcamUrl changes. + }, [props.webcamUrl]); + + return ( + <> + + + + + + ); +} diff --git a/components/YSTVCalendar.tsx b/components/YSTVCalendar.tsx deleted file mode 100644 index ddaf6bfa..00000000 --- a/components/YSTVCalendar.tsx +++ /dev/null @@ -1,347 +0,0 @@ -"use client"; -import FullCalendar from "@fullcalendar/react"; -import dayGridPlugin from "@fullcalendar/daygrid"; -import timeGridPlugin from "@fullcalendar/timegrid"; -import listPlugin from "@fullcalendar/list"; -import { EventInput, formatDate } from "@fullcalendar/core"; -import { useRouter } from "next/navigation"; -import { useMediaQuery } from "@mantine/hooks"; -import { - CalendarType, - academicYears, - getNextPeriod, - Holiday, -} from "uoy-week-calendar/dist/calendar"; -import "./YSTVCalendar.css"; -import dayjs from "dayjs"; -import weekOfYear from "dayjs/plugin/weekOfYear"; -import { ActionIcon, Menu, Select, Loader } from "@mantine/core"; -import { useRef } from "react"; -import * as Sentry from "@sentry/nextjs"; -import { TbCheck, TbFilter } from "react-icons/tb"; -import findLast from "core-js-pure/stable/array/find-last"; -import { useUserPreferences } from "./UserContext"; - -dayjs.extend(weekOfYear); - -let didLogAcademicYearError = false; - -function getUoYWeekName(date: Date) { - if (!Array.isArray(academicYears)) { - // Something has gone badly wrong (https://linear.app/ystv/issue/WEB-100/typeerror-cacademicyearsfindlast-is-not-a-function-in) - if (!didLogAcademicYearError) { - Sentry.captureException(new Error("Failed to load academicYears"), { - extra: { - academicYears, - }, - }); - didLogAcademicYearError = true; - } - return "Week " + dayjs(date).week(); - } - const academicYear = findLast( - academicYears, - (x) => x.periods[0].startDate.getTime() <= date.getTime(), - ); - if (!academicYear) { - return "Week " + dayjs(date).week(); - } - let period = academicYear.periods[0]; - let nextPeriod = getNextPeriod(period, academicYear); - while (nextPeriod.startDate.getTime() <= date.getTime()) { - period = nextPeriod; - nextPeriod = getNextPeriod(period, academicYear); - } - - if (period instanceof Holiday) { - return period.name + " Vacation"; - } - - const name = period - .getWeekName(date, CalendarType.UNDERGRADUATE) - .replace("Teaching", ""); - // HACK pending upstream changes - if (name.includes("(")) { - return name.replace(/^.*\((.+)\)$/, "$1"); - } - return name; -} - -export default function YSTVCalendar({ - events, - selectedDate, - selectedFilter, - selectedView: selectedView, -}: { - events: Event[]; - selectedDate: Date; - selectedFilter?: string; - selectedView?: string; -}) { - const currentDate = new Date(); - - const router = useRouter(); - const prefs = useUserPreferences(); - - const isMobileView = useMediaQuery("(max-width: 650px)", undefined, { - getInitialValueInEffect: true, - }); - - const initialView = - selectedView ?? (isMobileView ? "dayGridWeek" : "dayGridMonth"); - - const calendarRef = useRef(null); - - const viewsList = [ - { value: "dayGridMonth", label: "Month" }, - { value: "dayGridWeek", label: "Week" }, - { value: "listMonth", label: "List" }, - { value: "timeGridDay", label: "Day" }, - ]; - - const updateCalendarURL = ({ - newDate, - newFilter, - newView, - }: { - newDate?: Date; - newFilter?: String; - newView?: String; - }) => { - const date = newDate ?? selectedDate; - const view = newView ?? initialView; - const filter = newFilter ?? selectedFilter; - router.push( - `/calendar?year=${date.getFullYear()}&month=${ - date.getMonth() + 1 - }&day=${date.getDate()}${!view || view === "all" ? "" : `&view=${view}`}${ - !filter || filter === "all" ? "" : `&filter=${filter}` - }`, - ); - }; - - if (isMobileView === undefined) - return ( -
- -
- ); - - return ( - <> -
- - - - - - - - Filter Events - , - disabled: true, - })} - onClick={() => updateCalendarURL({ newFilter: "all" })} - > - All - - , - disabled: true, - })} - onClick={() => updateCalendarURL({ newFilter: "vacant" })} - > - Vacant - - , - disabled: true, - })} - onClick={() => updateCalendarURL({ newFilter: "my" })} - > - My - - - - {isMobileView && calendarRef.current && ( -