diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..266baa3 --- /dev/null +++ b/.env.test @@ -0,0 +1,6 @@ +AUTH_SECRET="auth_secret" +AUTH_URL="http://127.0.0.1:3000" +HOSTNAME="127.0.0.1" +DATABASE_URL="file:/tmp/dev.db" +AUTH_AUTHENTIK_ID="id" +AUTH_AUTHENTIK_ISSUER="issuer" \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..b0d8710 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,34 @@ +name: tests +on: + push: + branches: + - main + - renovate/* + pull_request: +jobs: + tests: + name: Tests + runs-on: docker + container: + image: cypress/browsers:latest@sha256:9daea41366dfd1b72496bf3e8295eda215a6990c2dbe4f9ff4b8ba47342864fb + options: --user 1001 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - name: Enable corepack + run: corepack enable + + - name: Cypress run (e2e) + uses: https://github.com/cypress-io/github-action@v6 + with: + build: yarn cypress:build + start: yarn cypress:start_server + e2e: true + wait-on: 'http://127.0.0.1:3000' + + - name: Cypress run (component) + uses: https://github.com/cypress-io/github-action@v6 + with: + component: true + install: false diff --git a/.gitignore b/.gitignore index cda64ee..03ddb54 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* !.env.example +!.env.test # vercel .vercel @@ -45,3 +46,8 @@ next-env.d.ts /prisma/*.db* src/generated/* data + +# cypress +cypress/videos +cypress/screenshots +cypress/coverage diff --git a/Dockerfile b/Dockerfile index b60e118..29e8dfa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22-alpine@sha256:41e4389f3d988d2ed55392df4db1420ad048ae53324a8e2b7c6d19508288107e AS base +FROM node:22-alpine@sha256:5340cbfc2df14331ab021555fdd9f83f072ce811488e705b0e736b11adeec4bb AS base # ----- Dependencies ----- FROM base AS deps diff --git a/Dockerfile.dev b/Dockerfile.dev index 4467c5f..a77f9a8 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM node:22-alpine@sha256:41e4389f3d988d2ed55392df4db1420ad048ae53324a8e2b7c6d19508288107e +FROM node:22-alpine@sha256:5340cbfc2df14331ab021555fdd9f83f072ce811488e705b0e736b11adeec4bb WORKDIR /app diff --git a/README.md b/README.md index d9ca71b..56fa41d 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,6 @@ This project is built with a modern tech stack: yarn install ``` 3. **Set up environment variables:** - - You will need to create an `AUTH_SECRET`. You can generate one using the following command: ```bash npx auth secret @@ -97,7 +96,6 @@ This project is built with a modern tech stack: ``` 4. **Apply database migrations (Prisma):** - - Ensure your Prisma schema (`prisma/schema.prisma`) is defined. - Setup/update the database with these commands: ```bash diff --git a/cypress.config.ts b/cypress.config.ts new file mode 100644 index 0000000..bebdaa5 --- /dev/null +++ b/cypress.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'cypress'; + +export default defineConfig({ + component: { + devServer: { + framework: 'next', + bundler: 'webpack', + }, + }, + + e2e: { + setupNodeEvents(on, config) { + // implement node event listeners here + }, + }, +}); diff --git a/cypress/e2e/auth-user.ts b/cypress/e2e/auth-user.ts new file mode 100644 index 0000000..5b02ab9 --- /dev/null +++ b/cypress/e2e/auth-user.ts @@ -0,0 +1,12 @@ +export default function authUser() { + cy.visit('http://127.0.0.1:3000/login'); + cy.getBySel('login-header').should('exist'); + cy.getBySel('login-form').should('exist'); + cy.getBySel('email-input').should('exist'); + cy.getBySel('password-input').should('exist'); + cy.getBySel('login-button').should('exist'); + cy.getBySel('email-input').type('cypress@example.com'); + cy.getBySel('password-input').type('Password123!'); + cy.getBySel('login-button').click(); + cy.url().should('include', '/home'); +} diff --git a/cypress/e2e/event-create.cy.ts b/cypress/e2e/event-create.cy.ts new file mode 100644 index 0000000..a74f770 --- /dev/null +++ b/cypress/e2e/event-create.cy.ts @@ -0,0 +1,9 @@ +import authUser from './auth-user'; + +describe('event creation', () => { + it('loads', () => { + authUser(); + + // cy.visit('http://127.0.0.1:3000/events/new'); // TODO: Add event creation tests + }); +}); diff --git a/cypress/e2e/login.cy.ts b/cypress/e2e/login.cy.ts new file mode 100644 index 0000000..d9461d1 --- /dev/null +++ b/cypress/e2e/login.cy.ts @@ -0,0 +1,45 @@ +describe('login and register', () => { + it('loads', () => { + cy.visit('http://127.0.0.1:3000/'); + + cy.getBySel('login-header').should('exist'); + }); + + it('shows register form', () => { + cy.visit('http://127.0.0.1:3000/'); + + cy.getBySel('register-switch').click(); + + cy.getBySel('register-form').should('exist'); + cy.getBySel('first-name-input').should('exist'); + cy.getBySel('last-name-input').should('exist'); + cy.getBySel('email-input').should('exist'); + cy.getBySel('username-input').should('exist'); + cy.getBySel('password-input').should('exist'); + cy.getBySel('confirm-password-input').should('exist'); + cy.getBySel('register-button').should('exist'); + }); + + it('allows to register', async () => { + cy.visit('http://127.0.0.1:3000/'); + + cy.getBySel('register-switch').click(); + + cy.getBySel('first-name-input').type('Test'); + cy.getBySel('last-name-input').type('User'); + cy.getBySel('email-input').type('test@example.com'); + cy.getBySel('username-input').type('testuser'); + cy.getBySel('password-input').type('Password123!'); + cy.getBySel('confirm-password-input').type('Password123!'); + cy.getBySel('register-button').click(); + cy.getBySel('login-header').should('exist'); + cy.getBySel('login-form').should('exist'); + cy.getBySel('email-input').should('exist'); + cy.getBySel('password-input').should('exist'); + cy.getBySel('login-button').should('exist'); + cy.getBySel('email-input').type('test@example.com'); + cy.getBySel('password-input').type('Password123!'); + cy.getBySel('login-button').click(); + cy.url().should('include', '/home'); + }); +}); diff --git a/cypress/e2e/seed.ts b/cypress/e2e/seed.ts new file mode 100644 index 0000000..c3cd389 --- /dev/null +++ b/cypress/e2e/seed.ts @@ -0,0 +1,29 @@ +import { PrismaClient } from '../../src/generated/prisma'; + +const prisma = new PrismaClient(); + +export default async function requireUser() { + await prisma.$transaction(async (tx) => { + const { id } = await tx.user.create({ + data: { + email: 'cypress@example.com', + name: 'cypress', + password_hash: + '$2a$10$FmkVRHXzMb63dLHHwG1mDOepZJirL.U964wU/3Xr7cFis8XdRh8sO', + first_name: 'Cypress', + last_name: 'Tester', + emailVerified: new Date(), + }, + }); + + await tx.account.create({ + data: { + userId: id, + type: 'credentials', + provider: 'credentials', + providerAccountId: id, + }, + }); + }); +} +requireUser(); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts new file mode 100644 index 0000000..59717f5 --- /dev/null +++ b/cypress/support/commands.ts @@ -0,0 +1,62 @@ +/// +// *********************************************** +// This example commands.ts shows you how to +// create various custom commands and overwrite +// existing commands. +// +// For more comprehensive examples of custom +// commands please read more here: +// https://on.cypress.io/custom-commands +// *********************************************** +// +// +// -- This is a parent command -- +// Cypress.Commands.add('login', (email, password) => { ... }) +// +// +// -- This is a child command -- +// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) +// +// +// -- This is a dual command -- +// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) +// +// +// -- This will overwrite an existing command -- +// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) +// +// declare global { +// namespace Cypress { +// interface Chainable { +// login(email: string, password: string): Chainable +// drag(subject: string, options?: Partial): Chainable +// dismiss(subject: string, options?: Partial): Chainable +// visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable +// } +// } +// } + +Cypress.Commands.add('getBySel', (selector, ...args) => { + return cy.get(`[data-cy=${selector}]`, ...args); +}); + +Cypress.Commands.add('getBySelLike', (selector, ...args) => { + return cy.get(`[data-cy*=${selector}]`, ...args); +}); + +declare global { + namespace Cypress { + interface Chainable { + getBySel( + selector: string, + ...args: any[] + ): Chainable>; + getBySelLike( + selector: string, + ...args: any[] + ): Chainable>; + } + } +} + +export {}; diff --git a/cypress/support/component-index.html b/cypress/support/component-index.html new file mode 100644 index 0000000..2cbfac6 --- /dev/null +++ b/cypress/support/component-index.html @@ -0,0 +1,14 @@ + + + + + + + Components App + +
+ + +
+ + diff --git a/cypress/support/component.ts b/cypress/support/component.ts new file mode 100644 index 0000000..b1f1c92 --- /dev/null +++ b/cypress/support/component.ts @@ -0,0 +1,38 @@ +// *********************************************************** +// This example support/component.ts is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +import '@/app/globals.css'; + +// Import commands.js using ES2015 syntax: +import './commands'; + +import { mount } from 'cypress/react'; + +// Augment the Cypress namespace to include type definitions for +// your custom command. +// Alternatively, can be defined in cypress/support/component.d.ts +// with a at the top of your spec. +declare global { + namespace Cypress { + interface Chainable { + mount: typeof mount; + } + } +} + +Cypress.Commands.add('mount', mount); + +// Example use: +// cy.mount() diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts new file mode 100644 index 0000000..e66558e --- /dev/null +++ b/cypress/support/e2e.ts @@ -0,0 +1,17 @@ +// *********************************************************** +// This example support/e2e.ts is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import './commands'; diff --git a/exportSwagger.ts b/exportSwagger.ts index 1eb2837..6b6df1e 100644 --- a/exportSwagger.ts +++ b/exportSwagger.ts @@ -22,16 +22,15 @@ async function exportSwagger() { ); await Promise.all( - filesToImport.map((file) => { - return import(file) - .then((module) => { - if (module.default) { - module.default(registry); - } - }) - .catch((error) => { - console.error(`Error importing ${file}:`, error); - }); + filesToImport.map(async (file) => { + try { + const moduleImp = await import(file); + if (moduleImp.default) { + moduleImp.default(registry); + } + } catch (error) { + console.error(`Error importing ${file}:`, error); + } }), ); diff --git a/package.json b/package.json index c5c77fb..9a33a39 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "next dev --turbopack", "build": "prettier --check . && next build", - "start": "next start", + "start": "node .next/standalone/server.js", "lint": "next lint", "format": "prettier --write .", "prisma:migrate": "dotenv -e .env.local -- prisma migrate dev", @@ -15,7 +15,11 @@ "prisma:migrate:reset": "dotenv -e .env.local -- prisma migrate reset", "dev_container": "docker compose -f docker-compose.dev.yml up --watch --build", "swagger:generate": "ts-node -r tsconfig-paths/register exportSwagger.ts", - "orval:generate": "orval" + "orval:generate": "orval", + "cypress:build": "rm -rf /tmp/dev.db && DATABASE_URL=\"file:/tmp/dev.db\" yarn prisma:generate && yarn swagger:generate && yarn orval:generate && DATABASE_URL=\"file:/tmp/dev.db\" yarn prisma:db:push && prettier --check . && NODE_ENV=test next build", + "cypress:start_server": "DATABASE_URL=\"file:/tmp/dev.db\" ts-node cypress/e2e/seed.ts && cp .env.test .next/standalone && cp public .next/standalone/ -r && cp .next/static/ .next/standalone/.next/ -r && NODE_ENV=test HOSTNAME=\"0.0.0.0\" dotenv -e .env.test -- node .next/standalone/server.js", + "cypress:open": "cypress open", + "cypress:run": "cypress run" }, "dependencies": { "@asteasolutions/zod-to-openapi": "^8.0.0-beta.4", @@ -27,47 +31,63 @@ "@fortawesome/react-fontawesome": "^0.2.2", "@hookform/resolvers": "^5.0.1", "@prisma/client": "^6.9.0", - "@radix-ui/react-dropdown-menu": "^2.1.14", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-collapsible": "^1.1.11", + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-hover-card": "^1.1.13", "@radix-ui/react-label": "^2.1.6", - "@radix-ui/react-scroll-area": "^1.2.8", + "@radix-ui/react-popover": "^1.1.14", + "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-select": "^2.2.4", - "@radix-ui/react-separator": "^1.1.6", - "@radix-ui/react-slot": "^1.2.2", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.4", "@radix-ui/react-tabs": "^1.1.11", + "@radix-ui/react-tooltip": "^1.2.7", "@tanstack/react-query": "^5.80.7", "bcryptjs": "^3.0.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "lucide-react": "^0.511.0", - "next": "15.4.0-canary.92", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", + "lucide-react": "^0.523.0", + "next": "15.3.4", "next-auth": "^5.0.0-beta.25", + "next-swagger-doc": "^0.4.1", "next-themes": "^0.4.6", - "react": "^19.0.0", + "react": "^19.1.0", + "react-big-calendar": "^1.18.0", + "react-datepicker": "^8.4.0", + "react-day-picker": "^9.7.0", "react-dom": "^19.0.0", + "react-error-boundary": "^6.0.0", "react-hook-form": "^7.56.4", + "sonner": "^2.0.5", "swagger-ui-react": "^5.24.1", "tailwind-merge": "^3.2.0", - "zod": "^3.25.60" + "zod": "^3.25.60", + "zod-validation-error": "^3.5.2" }, "devDependencies": { "@eslint/eslintrc": "3.3.1", - "@tailwindcss/postcss": "4.1.10", - "@types/node": "22.15.32", + "@tailwindcss/postcss": "4.1.11", + "@types/node": "22.15.33", "@types/react": "19.1.8", + "@types/react-big-calendar": "1.16.2", "@types/react-dom": "19.1.6", - "@types/swagger-ui-react": "5", + "@types/swagger-ui-react": "5.18.0", "@types/webpack-env": "1.18.8", + "cypress": "14.5.0", "dotenv-cli": "8.0.0", "eslint": "9.29.0", "eslint-config-next": "15.3.4", "eslint-config-prettier": "10.1.5", "orval": "7.10.0", "postcss": "8.5.6", - "prettier": "3.5.3", + "prettier": "3.6.2", "prisma": "6.10.1", - "tailwindcss": "4.1.10", + "tailwindcss": "4.1.11", "ts-node": "10.9.2", "tsconfig-paths": "4.2.0", "tw-animate-css": "1.3.4", diff --git a/src/app/(main)/events/[eventID]/page.tsx b/src/app/(main)/events/[eventID]/page.tsx new file mode 100644 index 0000000..bfc390d --- /dev/null +++ b/src/app/(main)/events/[eventID]/page.tsx @@ -0,0 +1,238 @@ +'use client'; + +import React, { useState } from 'react'; +import Logo from '@/components/misc/logo'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import { + useDeleteApiEventEventID, + useGetApiEventEventID, +} from '@/generated/api/event/event'; +import { useGetApiUserMe } from '@/generated/api/user/user'; +import { RedirectButton } from '@/components/buttons/redirect-button'; +import { useSession } from 'next-auth/react'; +import ParticipantListEntry from '@/components/custom-ui/participant-list-entry'; +import { useParams, useRouter } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { ToastInner } from '@/components/misc/toast-inner'; +import { toast } from 'sonner'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; + +export default function ShowEvent() { + const session = useSession(); + const router = useRouter(); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + + const { eventID: eventID } = useParams<{ eventID: string }>(); + + // Fetch event data + const { data: eventData, isLoading, error } = useGetApiEventEventID(eventID); + const { data: userData, isLoading: userLoading } = useGetApiUserMe(); + const deleteEvent = useDeleteApiEventEventID(); + + if (isLoading || userLoading) { + return ( +
+ Loading... +
+ ); + } + if (error || !eventData?.data?.event) { + return ( +
+ Error loading event. +
+ ); + } + + const event = eventData.data.event; + const organiserName = userData?.data.user?.name || 'Unknown User'; + + // Format dates & times for display + const formatDate = (isoString?: string) => { + if (!isoString) return '-'; + return new Date(isoString).toLocaleDateString(); + }; + const formatTime = (isoString?: string) => { + if (!isoString) return '-'; + return new Date(isoString).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); + }; + + return ( +
+ + + + +
+
+
+
+ +
+
+

+ {event.title || 'Untitled Event'} +

+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+
+
+
+ + +
+
+
+ + +
+
+
+ {' '} +
+ {event.participants?.map((user) => ( + + ))} +
+
+
+ +
+
+ {session.data?.user?.id === event.organizer.id ? ( + + + + + + + Delete Event + + Are you sure you want to delete the event “ + {event.title}”? This action cannot be undone. + + + + + + + + + ) : null} +
+
+ {session.data?.user?.id === event.organizer.id ? ( + + ) : null} +
+
+
+
+
+
+
+ ); +} diff --git a/src/app/(main)/events/edit/[eventID]/page.tsx b/src/app/(main)/events/edit/[eventID]/page.tsx new file mode 100644 index 0000000..42c6e8b --- /dev/null +++ b/src/app/(main)/events/edit/[eventID]/page.tsx @@ -0,0 +1,24 @@ +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import EventForm from '@/components/forms/event-form'; +import { Suspense } from 'react'; + +export default async function Page({ + params, +}: { + params: Promise<{ eventID: string }>; +}) { + const eventID = (await params).eventID; + return ( +
+ + + + + + + + + +
+ ); +} diff --git a/src/app/(main)/events/new/page.tsx b/src/app/(main)/events/new/page.tsx new file mode 100644 index 0000000..2db7ae2 --- /dev/null +++ b/src/app/(main)/events/new/page.tsx @@ -0,0 +1,21 @@ +import { ThemePicker } from '@/components/misc/theme-picker'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import EventForm from '@/components/forms/event-form'; +import { Suspense } from 'react'; + +export default function NewEvent() { + return ( +
+
{}
+ + + + + + + + + +
+ ); +} diff --git a/src/app/(main)/events/page.tsx b/src/app/(main)/events/page.tsx new file mode 100644 index 0000000..f0391dd --- /dev/null +++ b/src/app/(main)/events/page.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { RedirectButton } from '@/components/buttons/redirect-button'; +import EventListEntry from '@/components/custom-ui/event-list-entry'; +import { Label } from '@/components/ui/label'; +import { useGetApiEvent } from '@/generated/api/event/event'; + +export default function Events() { + const { data: eventsData, isLoading, error } = useGetApiEvent(); + + if (isLoading) return
Loading...
; + if (error) + return ( +
Error loading events
+ ); + + const events = eventsData?.data?.events || []; + + return ( +
+ {/* Heading */} +

+ My Events +

+ + {/* Scrollable event list */} +
+
+ {events.length > 0 ? ( + events.map((event) => ( + + )) + ) : ( +
+ + +
+ )} +
+
+
+ ); +} diff --git a/src/app/(main)/home/page.tsx b/src/app/(main)/home/page.tsx new file mode 100644 index 0000000..1cf8a90 --- /dev/null +++ b/src/app/(main)/home/page.tsx @@ -0,0 +1,17 @@ +'use client'; + +import Calendar from '@/components/calendar'; +import { useGetApiUserMe } from '@/generated/api/user/user'; + +export default function Home() { + const { data } = useGetApiUserMe(); + + return ( +
+ +
+ ); +} diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx new file mode 100644 index 0000000..7106e70 --- /dev/null +++ b/src/app/(main)/layout.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { cookies } from 'next/headers'; + +import { AppSidebar } from '@/components/custom-ui/app-sidebar'; +import SidebarProviderWrapper from '@/components/wrappers/sidebar-provider'; +import Header from '@/components/misc/header'; + +export default async function Layout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const cookieStore = await cookies(); + const defaultOpen = cookieStore.get('sidebar_state')?.value === 'true'; + return ( + <> + + +
{children}
+
+ + ); +} diff --git a/src/app/api/user/[user]/calendar/route.ts b/src/app/api/user/[user]/calendar/route.ts index f6b6098..62142e9 100644 --- a/src/app/api/user/[user]/calendar/route.ts +++ b/src/app/api/user/[user]/calendar/route.ts @@ -136,15 +136,15 @@ export const GET = auth(async function GET(req, { params }) { start_time: 'asc', }, select: { - id: requestUserId === requestedUserId ? true : false, - reason: requestUserId === requestedUserId ? true : false, + id: true, + reason: true, start_time: true, end_time: true, - is_recurring: requestUserId === requestedUserId ? true : false, - recurrence_end_date: requestUserId === requestedUserId ? true : false, - rrule: requestUserId === requestedUserId ? true : false, - created_at: requestUserId === requestedUserId ? true : false, - updated_at: requestUserId === requestedUserId ? true : false, + is_recurring: true, + recurrence_end_date: true, + rrule: true, + created_at: true, + updated_at: true, }, }, }, @@ -167,6 +167,7 @@ export const GET = auth(async function GET(req, { params }) { calendar.push({ ...event.meeting, type: 'event' }); } else { calendar.push({ + id: event.meeting.id, start_time: event.meeting.start_time, end_time: event.meeting.end_time, type: 'blocked_private', @@ -182,6 +183,7 @@ export const GET = auth(async function GET(req, { params }) { calendar.push({ ...event, type: 'event' }); } else { calendar.push({ + id: event.id, start_time: event.start_time, end_time: event.end_time, type: 'blocked_private', @@ -190,23 +192,35 @@ export const GET = auth(async function GET(req, { params }) { } for (const slot of requestedUser.blockedSlots) { - calendar.push({ - start_time: slot.start_time, - end_time: slot.end_time, - id: slot.id, - reason: slot.reason, - is_recurring: slot.is_recurring, - recurrence_end_date: slot.recurrence_end_date, - rrule: slot.rrule, - created_at: slot.created_at, - updated_at: slot.updated_at, - type: - requestUserId === requestedUserId ? 'blocked_owned' : 'blocked_private', - }); + if (requestUserId === requestedUserId) { + calendar.push({ + start_time: slot.start_time, + end_time: slot.end_time, + id: slot.id, + reason: slot.reason, + is_recurring: slot.is_recurring, + recurrence_end_date: slot.recurrence_end_date, + rrule: slot.rrule, + created_at: slot.created_at, + updated_at: slot.updated_at, + type: 'blocked_owned', + }); + } else { + calendar.push({ + start_time: slot.start_time, + end_time: slot.end_time, + id: slot.id, + type: 'blocked_private', + }); + } } return returnZodTypeCheckedResponse(UserCalendarResponseSchema, { success: true, - calendar, + calendar: calendar.filter( + (event, index, self) => + self.findIndex((e) => e.id === event.id && e.type === event.type) === + index, + ), }); }); diff --git a/src/app/api/user/[user]/calendar/validation.ts b/src/app/api/user/[user]/calendar/validation.ts index a0d179f..1572793 100644 --- a/src/app/api/user/[user]/calendar/validation.ts +++ b/src/app/api/user/[user]/calendar/validation.ts @@ -13,23 +13,28 @@ export const BlockedSlotSchema = zod start_time: eventStartTimeSchema, end_time: eventEndTimeSchema, type: zod.literal('blocked_private'), + id: zod.string(), }) .openapi('BlockedSlotSchema', { description: 'Blocked time slot in the user calendar', }); -export const OwnedBlockedSlotSchema = BlockedSlotSchema.extend({ - id: zod.string(), - reason: zod.string().nullish(), - is_recurring: zod.boolean().default(false), - recurrence_end_date: zod.date().nullish(), - rrule: zod.string().nullish(), - created_at: zod.date().nullish(), - updated_at: zod.date().nullish(), - type: zod.literal('blocked_owned'), -}).openapi('OwnedBlockedSlotSchema', { - description: 'Blocked slot owned by the user', -}); +export const OwnedBlockedSlotSchema = zod + .object({ + start_time: eventStartTimeSchema, + end_time: eventEndTimeSchema, + id: zod.string(), + reason: zod.string().nullish(), + is_recurring: zod.boolean().default(false), + recurrence_end_date: zod.date().nullish(), + rrule: zod.string().nullish(), + created_at: zod.date().nullish(), + updated_at: zod.date().nullish(), + type: zod.literal('blocked_owned'), + }) + .openapi('OwnedBlockedSlotSchema', { + description: 'Blocked slot owned by the user', + }); export const VisibleSlotSchema = EventSchema.omit({ organizer: true, diff --git a/src/app/api/user/me/password/route.ts b/src/app/api/user/me/password/route.ts new file mode 100644 index 0000000..0b92559 --- /dev/null +++ b/src/app/api/user/me/password/route.ts @@ -0,0 +1,122 @@ +import { auth } from '@/auth'; +import { prisma } from '@/prisma'; +import { updateUserPasswordServerSchema } from '../validation'; +import { + returnZodTypeCheckedResponse, + userAuthenticated, +} from '@/lib/apiHelpers'; +import { FullUserResponseSchema } from '../../validation'; +import { + ErrorResponseSchema, + ZodErrorResponseSchema, +} from '@/app/api/validation'; +import bcrypt from 'bcryptjs'; + +export const PATCH = auth(async function PATCH(req) { + const authCheck = userAuthenticated(req); + if (!authCheck.continue) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + authCheck.response, + authCheck.metadata, + ); + + const body = await req.json(); + const parsedBody = updateUserPasswordServerSchema.safeParse(body); + if (!parsedBody.success) + return returnZodTypeCheckedResponse( + ZodErrorResponseSchema, + { + success: false, + message: 'Invalid request data', + errors: parsedBody.error.issues, + }, + { status: 400 }, + ); + + const { current_password, new_password } = parsedBody.data; + + const dbUser = await prisma.user.findUnique({ + where: { + id: authCheck.user.id, + }, + include: { + accounts: true, + }, + }); + + if (!dbUser) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + { + success: false, + message: 'User not found', + }, + { status: 404 }, + ); + + if (!dbUser.password_hash) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + { + success: false, + message: 'User does not have a password set', + }, + { status: 400 }, + ); + + if ( + dbUser.accounts.length === 0 || + dbUser.accounts[0].provider !== 'credentials' + ) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + { + success: false, + message: 'Credentials login is not enabled for this user', + }, + { status: 400 }, + ); + + const isCurrentPasswordValid = await bcrypt.compare( + current_password, + dbUser.password_hash || '', + ); + + if (!isCurrentPasswordValid) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + { + success: false, + message: 'Current password is incorrect', + }, + { status: 401 }, + ); + + const hashedNewPassword = await bcrypt.hash(new_password, 10); + + const updatedUser = await prisma.user.update({ + where: { + id: dbUser.id, + }, + data: { + password_hash: hashedNewPassword, + }, + select: { + id: true, + name: true, + first_name: true, + last_name: true, + email: true, + image: true, + timezone: true, + created_at: true, + updated_at: true, + }, + }); + + return returnZodTypeCheckedResponse(FullUserResponseSchema, { + success: true, + user: updatedUser, + }); +}); diff --git a/src/app/api/user/me/password/swagger.ts b/src/app/api/user/me/password/swagger.ts new file mode 100644 index 0000000..0bc62f0 --- /dev/null +++ b/src/app/api/user/me/password/swagger.ts @@ -0,0 +1,43 @@ +import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; +import { FullUserResponseSchema } from '../../validation'; +import { updateUserPasswordServerSchema } from '../validation'; +import { + invalidRequestDataResponse, + notAuthenticatedResponse, + serverReturnedDataValidationErrorResponse, + userNotFoundResponse, +} from '@/lib/defaultApiResponses'; + +export default function registerSwaggerPaths(registry: OpenAPIRegistry) { + registry.registerPath({ + method: 'patch', + path: '/api/user/me/password', + description: 'Update the password of the currently authenticated user', + request: { + body: { + description: 'User password update request body', + required: true, + content: { + 'application/json': { + schema: updateUserPasswordServerSchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'User information updated successfully', + content: { + 'application/json': { + schema: FullUserResponseSchema, + }, + }, + }, + ...invalidRequestDataResponse, + ...notAuthenticatedResponse, + ...userNotFoundResponse, + ...serverReturnedDataValidationErrorResponse, + }, + tags: ['User'], + }); +} diff --git a/src/app/api/user/me/route.ts b/src/app/api/user/me/route.ts index 5ba9792..5571a6b 100644 --- a/src/app/api/user/me/route.ts +++ b/src/app/api/user/me/route.ts @@ -8,6 +8,7 @@ import { import { FullUserResponseSchema } from '../validation'; import { ErrorResponseSchema, + SuccessResponseSchema, ZodErrorResponseSchema, } from '@/app/api/validation'; @@ -117,3 +118,43 @@ export const PATCH = auth(async function PATCH(req) { { status: 200 }, ); }); + +export const DELETE = auth(async function DELETE(req) { + const authCheck = userAuthenticated(req); + if (!authCheck.continue) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + authCheck.response, + authCheck.metadata, + ); + + const dbUser = await prisma.user.findUnique({ + where: { + id: authCheck.user.id, + }, + }); + if (!dbUser) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + { + success: false, + message: 'User not found', + }, + { status: 404 }, + ); + + await prisma.user.delete({ + where: { + id: authCheck.user.id, + }, + }); + + return returnZodTypeCheckedResponse( + SuccessResponseSchema, + { + success: true, + message: 'User deleted successfully', + }, + { status: 200 }, + ); +}); diff --git a/src/app/api/user/me/swagger.ts b/src/app/api/user/me/swagger.ts index e0a36a1..6a9e375 100644 --- a/src/app/api/user/me/swagger.ts +++ b/src/app/api/user/me/swagger.ts @@ -7,6 +7,7 @@ import { serverReturnedDataValidationErrorResponse, userNotFoundResponse, } from '@/lib/defaultApiResponses'; +import { SuccessResponseSchema } from '../../validation'; export default function registerSwaggerPaths(registry: OpenAPIRegistry) { registry.registerPath({ @@ -60,4 +61,24 @@ export default function registerSwaggerPaths(registry: OpenAPIRegistry) { }, tags: ['User'], }); + + registry.registerPath({ + method: 'delete', + path: '/api/user/me', + description: 'Delete the currently authenticated user', + responses: { + 200: { + description: 'User deleted successfully', + content: { + 'application/json': { + schema: SuccessResponseSchema, + }, + }, + }, + ...notAuthenticatedResponse, + ...userNotFoundResponse, + ...serverReturnedDataValidationErrorResponse, + }, + tags: ['User'], + }); } diff --git a/src/app/api/user/me/validation.ts b/src/app/api/user/me/validation.ts index 49c6219..66f07cc 100644 --- a/src/app/api/user/me/validation.ts +++ b/src/app/api/user/me/validation.ts @@ -4,6 +4,8 @@ import { lastNameSchema, newUserEmailServerSchema, newUserNameServerSchema, + passwordSchema, + timezoneSchema, } from '@/app/api/user/validation'; // ---------------------------------------- @@ -16,6 +18,16 @@ export const updateUserServerSchema = zod.object({ first_name: firstNameSchema.optional(), last_name: lastNameSchema.optional(), email: newUserEmailServerSchema.optional(), - image: zod.string().optional(), - timezone: zod.string().optional(), + image: zod.url().optional(), + timezone: timezoneSchema.optional(), }); + +export const updateUserPasswordServerSchema = zod + .object({ + current_password: zod.string().min(1, 'Current password is required'), + new_password: passwordSchema, + confirm_new_password: passwordSchema, + }) + .refine((data) => data.new_password === data.confirm_new_password, { + message: 'New password and confirm new password must match', + }); diff --git a/src/app/api/user/validation.ts b/src/app/api/user/validation.ts index 79b1e7e..89b8ba4 100644 --- a/src/app/api/user/validation.ts +++ b/src/app/api/user/validation.ts @@ -1,6 +1,7 @@ import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; import { prisma } from '@/prisma'; import zod from 'zod/v4'; +import { allTimeZones } from '@/lib/timezones'; extendZodWithOpenApi(zod); @@ -107,6 +108,15 @@ export const passwordSchema = zod 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character', ); +// ---------------------------------------- +// +// Timezone Validation +// +// ---------------------------------------- +export const timezoneSchema = zod.enum(allTimeZones).openapi('Timezone', { + description: 'Valid timezone from the list of supported timezones', +}); + // ---------------------------------------- // // User Schema Validation (for API responses) @@ -119,8 +129,11 @@ export const FullUserSchema = zod first_name: zod.string().nullish(), last_name: zod.string().nullish(), email: zod.email(), - image: zod.string().nullish(), - timezone: zod.string(), + image: zod.url().nullish(), + timezone: zod + .string() + .refine((i) => (allTimeZones as string[]).includes(i)) + .nullish(), created_at: zod.date(), updated_at: zod.date(), }) diff --git a/src/app/globals.css b/src/app/globals.css index f85cb2f..bc18178 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -9,6 +9,7 @@ --font-heading: 'Comfortaa', sans-serif; --font-label: 'Varela Round', sans-serif; --font-button: 'Varela Round', sans-serif; + --font-sans: var(--font-label); --transparent: transparent; @@ -28,11 +29,12 @@ --background: var(--neutral-800); --background-reversed: var(--neutral-000); - --base: var(--neutral-800); + --basecl: var(--neutral-800); --text: var(--neutral-000); --text-alt: var(--neutral-900); --text-input: var(--text); --text-muted-input: var(--neutral-450); + --text-muted: var(--neutral-300); --muted-input: var(--neutral-600); --background-disabled: var(--neutral-500); --text-disabled: var(--neutral-700); @@ -48,13 +50,27 @@ --active-secondary: oklch(0.4254 0.133 272.15); --disabled-secondary: oklch(0.4937 0.1697 271.26 / 0.5); + --destructive: oklch(60.699% 0.20755 25.945); + --hover-destructive: oklch(60.699% 0.20755 25.945 / 0.8); + --active-destructive: oklch(50.329% 0.17084 25.842); + --disabled-destructive: oklch(60.699% 0.20755 25.945 / 0.4); + --muted: var(--color-neutral-700); --hover-muted: var(--color-neutral-600); --active-muted: var(--color-neutral-400); --disabled-muted: var(--color-neutral-400); + --toaster-default-bg: var(--color-neutral-150); + --toaster-success-bg: oklch(54.147% 0.09184 144.208); + --toaster-error-bg: oklch(52.841% 0.10236 27.274); + --toaster-info-bg: oklch(44.298% 0.05515 259.369); + --toaster-warning-bg: oklch(61.891% 0.07539 102.943); + --toaster-notification-bg: var(--color-neutral-150); + --card: var(--neutral-800); + --sidebar-width-icon: 32px; + /* ------------------- */ --foreground: oklch(0.13 0.028 261.692); @@ -77,8 +93,6 @@ --accent-foreground: oklch(0.21 0.034 264.665); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.928 0.006 264.531); --input: oklch(0.928 0.006 264.531); @@ -95,23 +109,79 @@ --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0.002 247.839); + --sidebar: var(--background); - --sidebar-foreground: oklch(0.13 0.028 261.692); + --sidebar-foreground: var(--text); --sidebar-primary: oklch(0.21 0.034 264.665); - --sidebar-primary-foreground: oklch(0.985 0.002 247.839); + --sidebar-primary-foreground: var(--text); --sidebar-accent: oklch(0.967 0.003 264.542); - --sidebar-accent-foreground: oklch(0.21 0.034 264.665); + --sidebar-accent-foreground: var(--text); --sidebar-border: oklch(0.928 0.006 264.531); --sidebar-ring: oklch(0.707 0.022 261.325); } +h1 { + font-family: var(--font-heading); + font-size: 40px; + font-style: normal; + font-weight: 700; + line-height: normal; +} + +h2 { + font-family: var(--font-heading); + font-size: 36px; + font-style: normal; + font-weight: 600; + line-height: normal; +} + +h3 { + font-family: var(--font-heading); + font-size: 32px; + font-style: normal; + font-weight: 600; + line-height: normal; +} + +h4 { + font-family: var(--font-heading); + font-size: 28px; + font-style: normal; + font-weight: 600; + line-height: normal; +} + +h5 { + font-family: var(--font-heading); + font-size: 26px; + font-style: normal; + font-weight: 600; + line-height: normal; +} + +h6 { + font-family: var(--font-heading); + font-size: 20px; + font-style: normal; + font-weight: 600; + line-height: normal; +} + +p { + font-family: var(--font-label); + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: normal; +} + @font-face { font-family: 'Comfortaa'; font-style: normal; @@ -150,11 +220,12 @@ --color-background: var(--neutral-750); --color-background-reversed: var(--background-reversed); - --color-base: var(--neutral-800); + --color-basecl: var(--neutral-800); --color-text: var(--text); --color-text-alt: var(--text-alt); --color-text-input: var(--text-input); --color-text-muted-input: var(--text-muted-input); + --color-text-muted: var(--text-muted); --color-muted-input: var(--muted-input); --color-background-disabled: var(--neutral-500); @@ -171,11 +242,23 @@ --color-active-secondary: var(--active-secondary); --color-disabled-secondary: var(--disabled-secondary); + --color-destructive: var(--destructive); + --color-hover-destructive: var(--hover-destructive); + --color-active-destructive: var(--active-destructive); + --color-disabled-destructive: var(--disabled-destructive); + --color-muted: var(--muted); --color-hover-muted: var(--hover-muted); --color-active-muted: var(--active-muted); --color-disabled-muted: var(--disabled-muted); + --color-toaster-default-bg: var(--toaster-default-bg); + --color-toaster-success-bg: var(--toaster-success-bg); + --color-toaster-error-bg: var(--toaster-error-bg); + --color-toaster-info-bg: var(--toaster-info-bg); + --color-toaster-warning-bg: var(--toaster-warning-bg); + --color-toaster-notification-bg: var(--toaster-notification-bg); + /* Custom values */ --radius-sm: calc(var(--radius) - 4px); @@ -216,8 +299,6 @@ --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-border: var(--border); --color-input: var(--input); @@ -273,11 +354,12 @@ --background: var(--neutral-750); --background-reversed: var(--neutral-000); - --base: var(--neutral-750); + --basecl: var(--neutral-750); --text: var(--neutral-000); --text-alt: var(--neutral-900); --text-input: var(--text); --text-muted-input: var(--neutral-450); + --text-muted: var(--neutral-300); --muted-input: var(--neutral-500); --background-disabled: var(--neutral-500); --text-disabled: var(--neutral-700); @@ -292,11 +374,23 @@ --active-secondary: oklch(0.4471 0.15 271.61); --disabled-secondary: oklch(0.6065 0.213 271.11 / 0.4); + --destructive: oklch(0.58 0.2149 27.13); + --hover-destructive: oklch(0.58 0.2149 27.13 / 0.8); + --active-destructive: oklch(45.872% 0.16648 26.855); + --disabled-destructive: oklch(0.58 0.2149 27.13 / 0.4); + --muted: var(--color-neutral-650); --hover-muted: var(--color-neutral-500); --active-muted: var(--color-neutral-400); --disabled-muted: var(--color-neutral-400); + --toaster-default-bg: var(--color-neutral-150); + --toaster-success-bg: var(--color-green-200); + --toaster-error-bg: var(--color-red-200); + --toaster-info-bg: var(--color-blue-200); + --toaster-warning-bg: var(--color-yellow-200); + --toaster-notification-bg: var(--color-neutral-150); + --card: var(--neutral-750); /* ------------------- */ @@ -321,8 +415,6 @@ --accent-foreground: oklch(0.985 0.002 247.839); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); @@ -339,17 +431,17 @@ --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.21 0.034 264.665); + --sidebar: var(--background); - --sidebar-foreground: oklch(0.985 0.002 247.839); + --sidebar-foreground: var(--text); --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0.002 247.839); + --sidebar-primary-foreground: var(--text); --sidebar-accent: oklch(0.278 0.033 256.848); - --sidebar-accent-foreground: oklch(0.985 0.002 247.839); + --sidebar-accent-foreground: var(--text); --sidebar-border: oklch(1 0 0 / 10%); diff --git a/src/app/home/page.tsx b/src/app/home/page.tsx deleted file mode 100644 index 77f3cf8..0000000 --- a/src/app/home/page.tsx +++ /dev/null @@ -1,23 +0,0 @@ -'use client'; - -import { RedirectButton } from '@/components/buttons/redirect-button'; -import { ThemePicker } from '@/components/misc/theme-picker'; -import { useGetApiUserMe } from '@/generated/api/user/user'; - -export default function Home() { - const { data, isLoading } = useGetApiUserMe(); - - return ( -
-
{}
-
-

- Hello{' '} - {isLoading ? 'Loading...' : data?.data.user?.name || 'Unknown User'} -

- - -
-
- ); -} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 201a730..47cec2d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,7 +2,9 @@ import { ThemeProvider } from '@/components/wrappers/theme-provider'; import type { Metadata } from 'next'; import './globals.css'; -import { QueryProvider } from '@/components/query-provider'; +import { QueryProvider } from '@/components/wrappers/query-provider'; +import { Toaster } from '@/components/ui/sonner'; +import { SessionProvider } from 'next-auth/react'; export const metadata: Metadata = { title: 'MeetUp', @@ -50,14 +52,17 @@ export default function RootLayout({ - - {children} - + + + {children} + + + ); diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 76778ae..dcd207d 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -33,7 +33,10 @@ export default async function LoginPage() {
- + @@ -46,6 +49,7 @@ export default async function LoginPage() { key={provider.id} provider={provider.id} providerDisplayName={provider.name} + data-cy={'sso-login-button_' + provider.name.toLowerCase()} /> ))} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 563ebab..e0ad2a5 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -1,482 +1,5 @@ -import { Button } from '@/components/ui/button'; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from '@/components/ui/card'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { ScrollableSettingsWrapper } from '@/components/wrappers/settings-scroll'; -import { Switch } from '@/components/ui/switch'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; +import SettingsPage from '@/components/misc/settings-page'; -export default function SettingsPage() { - return ( -
-
- - - Account - Notifications - Calendar - Privacy - Appearance - - - - - - - Account Settings - - Manage your account details and preferences. - - - -
- - -
-
- - -

- Email is managed by your SSO provider. -

-
-
- - -

- Upload a new profile picture. -

-
-
- - -
- -
- - -
-
- -

- Permanently delete your account and all associated data. -

-
-
-
- - - - -
-
- - - - - - Notification Preferences - - Choose how you want to be notified. - - - -
- - -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
- - - - -
-
- - - - - - Calendar & Availability - - Manage your calendar display, default availability, and iCal - integrations. - - - -
- - Display - -
- - -
-
- - -
-
- - -
-
- -
- - Availability - -
- -

- Define your typical available hours (e.g., - Monday-Friday, 9 AM - 5 PM). -

- -
-
- -

- Min time before a booking can be made. -

-
- -
-
-
- -

- Max time in advance a booking can be made. -

- -
-
- -
- - iCalendar Integration - -
- - - -
-
- - - -
-
-
-
- - - - -
-
- - - - - - Sharing & Privacy - - Control who can see your calendar and book time with you. - - - -
- - -
-
- -

- (Override for Default Visibility) -
- - This setting will override the default visibility for - your calendar. You can set specific friends or groups to - see your full calendar details. - -

- -
-
- - -
-
- - -

- Prevent specific users from seeing your calendar or - booking time. -

-
-
-
- - - - -
-
- - - - - - Appearance - - Customize the look and feel of the application. - - - -
- - -
-
- - -
-
- - -
-
-
- - - - -
-
-
-
-
- ); +export default function Page() { + return ; } diff --git a/src/assets/logo/logo-export.ts b/src/assets/logo/logo-export.ts index 44681d3..17a0708 100644 --- a/src/assets/logo/logo-export.ts +++ b/src/assets/logo/logo-export.ts @@ -1,5 +1,5 @@ -export { default as logo_colored_combo_light } from '@/assets/logo/logo_colored_combo_light.svg'; -export { default as logo_colored_combo_dark } from '@/assets/logo/logo_colored_combo_dark.svg'; +export { default as logo_colored_combo_light } from '@/assets/logo/new/logo_colored_combo_light.svg'; +export { default as logo_colored_combo_dark } from '@/assets/logo/new/logo_colored_combo_dark.svg'; export { default as logo_colored_primary_light } from '@/assets/logo/logo_colored_primary_light.svg'; export { default as logo_colored_primary_dark } from '@/assets/logo/logo_colored_primary_dark.svg'; export { default as logo_colored_secondary_light } from '@/assets/logo/logo_colored_secondary_light.svg'; @@ -12,5 +12,5 @@ export { default as logo_mono_secondary_light } from '@/assets/logo/logo_mono_se export { default as logo_mono_secondary_dark } from '@/assets/logo/logo_mono_secondary_dark.svg'; export { default as logo_mono_submark_light } from '@/assets/logo/logo_mono_submark_light.svg'; export { default as logo_mono_submark_dark } from '@/assets/logo/logo_mono_submark_dark.svg'; -export { default as logo_colored_submark_light } from '@/assets/logo/logo_colored_submark_light.svg'; -export { default as logo_colored_submark_dark } from '@/assets/logo/logo_colored_submark_dark.svg'; +export { default as logo_colored_submark_light } from '@/assets/logo/new/logo_colored_submark_light.svg'; +export { default as logo_colored_submark_dark } from '@/assets/logo/new/logo_colored_submark_dark.svg'; diff --git a/src/assets/logo/new/logo_colored_combo_dark.svg b/src/assets/logo/new/logo_colored_combo_dark.svg new file mode 100644 index 0000000..ec4acae --- /dev/null +++ b/src/assets/logo/new/logo_colored_combo_dark.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/src/assets/logo/new/logo_colored_combo_light.svg b/src/assets/logo/new/logo_colored_combo_light.svg new file mode 100644 index 0000000..33e6c6d --- /dev/null +++ b/src/assets/logo/new/logo_colored_combo_light.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/src/assets/logo/new/logo_colored_submark_dark.svg b/src/assets/logo/new/logo_colored_submark_dark.svg new file mode 100644 index 0000000..efadbf0 --- /dev/null +++ b/src/assets/logo/new/logo_colored_submark_dark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/logo/new/logo_colored_submark_light.svg b/src/assets/logo/new/logo_colored_submark_light.svg new file mode 100644 index 0000000..cc7b3e1 --- /dev/null +++ b/src/assets/logo/new/logo_colored_submark_light.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/usericon/default/default-user-icon_dark.svg b/src/assets/usericon/default/default-user-icon_dark.svg new file mode 100644 index 0000000..b2a1cfb --- /dev/null +++ b/src/assets/usericon/default/default-user-icon_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/usericon/default/default-user-icon_light.svg b/src/assets/usericon/default/default-user-icon_light.svg new file mode 100644 index 0000000..60ba6d0 --- /dev/null +++ b/src/assets/usericon/default/default-user-icon_light.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/usericon/default/defaultusericon-export.tsx b/src/assets/usericon/default/defaultusericon-export.tsx new file mode 100644 index 0000000..d1b482d --- /dev/null +++ b/src/assets/usericon/default/defaultusericon-export.tsx @@ -0,0 +1,2 @@ +export { default as user_default_dark } from '@/assets/usericon/default/default-user-icon_dark.svg'; +export { default as user_default_light } from '@/assets/usericon/default/default-user-icon_light.svg'; diff --git a/src/components/buttons/notification-button.tsx b/src/components/buttons/notification-button.tsx new file mode 100644 index 0000000..f41f325 --- /dev/null +++ b/src/components/buttons/notification-button.tsx @@ -0,0 +1,31 @@ +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { NDot, NotificationDot } from '@/components/misc/notification-dot'; + +export function NotificationButton({ + dotVariant, + children, + ...props +}: { + dotVariant?: NDot; + children: React.ReactNode; +} & React.ComponentProps) { + return ( + + + + + + + ); +} diff --git a/src/components/buttons/redirect-button.tsx b/src/components/buttons/redirect-button.tsx index c4bf997..e67acc1 100644 --- a/src/components/buttons/redirect-button.tsx +++ b/src/components/buttons/redirect-button.tsx @@ -1,16 +1,18 @@ -import { Button } from '../ui/button'; +import { Button } from '@/components/ui/button'; import Link from 'next/link'; export function RedirectButton({ redirectUrl, buttonText, + className, }: { redirectUrl: string; buttonText: string; + className?: string; }) { return ( - + ); } diff --git a/src/components/buttons/sso-login-button.tsx b/src/components/buttons/sso-login-button.tsx index 644efce..013ef73 100644 --- a/src/components/buttons/sso-login-button.tsx +++ b/src/components/buttons/sso-login-button.tsx @@ -5,10 +5,11 @@ import { faOpenid } from '@fortawesome/free-brands-svg-icons'; export default function SSOLogin({ provider, providerDisplayName, + ...props }: { provider: string; providerDisplayName: string; -}) { +} & React.HTMLAttributes) { return (
Login with {providerDisplayName} diff --git a/src/components/calendar.tsx b/src/components/calendar.tsx new file mode 100644 index 0000000..a8d6005 --- /dev/null +++ b/src/components/calendar.tsx @@ -0,0 +1,256 @@ +'use client'; + +import { Calendar as RBCalendar, momentLocalizer } from 'react-big-calendar'; +import withDragAndDrop from 'react-big-calendar/lib/addons/dragAndDrop'; +import moment from 'moment'; +import '@/components/react-big-calendar.css'; +import 'react-big-calendar/lib/addons/dragAndDrop/styles.css'; +import CustomToolbar from '@/components/custom-toolbar'; +import React from 'react'; +import { useGetApiUserUserCalendar } from '@/generated/api/user/user'; +import { useRouter } from 'next/navigation'; +import { usePatchApiEventEventID } from '@/generated/api/event/event'; +import { useSession } from 'next-auth/react'; +import { UserCalendarSchemaItem } from '@/generated/api/meetup.schemas'; +import { QueryErrorResetBoundary } from '@tanstack/react-query'; +import { ErrorBoundary } from 'react-error-boundary'; +import { Button } from '@/components/ui/button'; +import { fromZodIssue } from 'zod-validation-error/v4'; +import type { $ZodIssue } from 'zod/v4/core'; + +moment.updateLocale('en', { + week: { + dow: 1, + doy: 4, + }, +}); + +const DaDRBCalendar = withDragAndDrop< + { + id: string; + start: Date; + end: Date; + type: UserCalendarSchemaItem['type']; + }, + { + id: string; + title: string; + type: UserCalendarSchemaItem['type']; + } +>(RBCalendar); + +const localizer = momentLocalizer(moment); + +export default function Calendar({ + userId, + height, +}: { + userId?: string; + height: string; +}) { + return ( + + {({ reset }) => ( + ( +
+ There was an error! +

+ {typeof error === 'string' + ? error + : error.errors + .map((e: $ZodIssue) => fromZodIssue(e).toString()) + .join(', ')} +

+ +
+ )} + > + {userId ? ( + + ) : ( + + )} +
+ )} +
+ ); +} + +function CalendarWithUserEvents({ + userId, + height, +}: { + userId: string; + height: string; +}) { + const sesstion = useSession(); + const [currentView, setCurrentView] = React.useState< + 'month' | 'week' | 'day' | 'agenda' | 'work_week' + >('week'); + const [currentDate, setCurrentDate] = React.useState(new Date()); + const router = useRouter(); + + const { data, refetch, error, isError } = useGetApiUserUserCalendar( + userId, + { + start: moment(currentDate) + .startOf( + currentView === 'agenda' + ? 'month' + : currentView === 'work_week' + ? 'week' + : currentView, + ) + .toISOString(), + end: moment(currentDate) + .endOf( + currentView === 'agenda' + ? 'month' + : currentView === 'work_week' + ? 'week' + : currentView, + ) + .toISOString(), + }, + { + query: { + refetchOnWindowFocus: true, + refetchOnReconnect: true, + refetchOnMount: true, + }, + }, + ); + + if (isError) { + throw error.response?.data || 'Failed to fetch calendar data'; + } + + const { mutate: patchEvent } = usePatchApiEventEventID({ + mutation: { + throwOnError(error) { + throw error.response?.data || 'Failed to update event'; + }, + }, + }); + + return ( + { + setCurrentDate(date); + }} + events={ + data?.data.calendar.map((event) => ({ + id: event.id, + title: event.type === 'event' ? event.title : 'Blocker', + start: new Date(event.start_time), + end: new Date(event.end_time), + type: event.type, + })) ?? [] + } + onSelectEvent={(event) => { + router.push(`/events/${event.id}`); + }} + onSelectSlot={(slotInfo) => { + router.push( + `/events/new?start=${slotInfo.start.toISOString()}&end=${slotInfo.end.toISOString()}`, + ); + }} + resourceIdAccessor={(event) => event.id} + resourceTitleAccessor={(event) => event.title} + startAccessor={(event) => event.start} + endAccessor={(event) => event.end} + selectable={sesstion.data?.user?.id === userId} + onEventDrop={(event) => { + const { start, end, event: droppedEvent } = event; + if (droppedEvent.type === 'blocked_private') return; + const startISO = new Date(start).toISOString(); + const endISO = new Date(end).toISOString(); + patchEvent( + { + eventID: droppedEvent.id, + data: { + start_time: startISO, + end_time: endISO, + }, + }, + { + onSuccess: () => { + refetch(); + }, + onError: (error) => { + console.error('Error updating event:', error); + }, + }, + ); + }} + onEventResize={(event) => { + const { start, end, event: resizedEvent } = event; + if (resizedEvent.type === 'blocked_private') return; + const startISO = new Date(start).toISOString(); + const endISO = new Date(end).toISOString(); + if (startISO === endISO) { + console.warn('Start and end times are the same, skipping resize.'); + return; + } + patchEvent( + { + eventID: resizedEvent.id, + data: { + start_time: startISO, + end_time: endISO, + }, + }, + { + onSuccess: () => { + refetch(); + }, + onError: (error) => { + console.error('Error resizing event:', error); + }, + }, + ); + }} + /> + ); +} + +function CalendarWithoutUserEvents({ height }: { height: string }) { + const [currentView, setCurrentView] = React.useState< + 'month' | 'week' | 'day' | 'agenda' | 'work_week' + >('week'); + const [currentDate, setCurrentDate] = React.useState(new Date()); + + return ( + { + setCurrentDate(date); + }} + /> + ); +} diff --git a/src/components/custom-toolbar.css b/src/components/custom-toolbar.css new file mode 100644 index 0000000..3fba69f --- /dev/null +++ b/src/components/custom-toolbar.css @@ -0,0 +1,114 @@ +/* Container der Toolbar */ +.custom-toolbar { + display: flex; + flex-direction: column; + gap: 12px; + padding: calc(var(--spacing) * 2); + padding-left: calc(50px + var(--spacing)); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; +} + +/* Anzeige des aktuellen Datums (Monat und Jahr) */ +.custom-toolbar .current-date { + font-weight: bold; + font-size: 12px; + text-align: center; + color: #ffffff; + background-color: #717171; + height: 37px; + border-radius: 11px; +} + +/* Navigationsbereich (Today, Prev, Next) */ +.custom-toolbar .navigation-controls { + display: flex; + gap: 8px; + justify-content: center; +} + +.custom-toolbar .navigation-controls button { + padding: 8px 12px; + color: #ffffff; + border: none; + border-radius: 11px; + font-size: 12px; + cursor: pointer; + transition: background-color 0.2s; +} + +.custom-toolbar .navigation-controls button:hover { + background-color: #1976d2; +} + +.custom-toolbar .navigation-controls button:active { + background-color: #1565c0; +} + +/* Dropdown-Bereich für Woche und Jahr */ +.custom-toolbar .dropdowns { + display: flex; + gap: 8px; + justify-content: center; + height: 30px; + font-size: 10px; + margin-top: 3.5px; + border-radius: 11px; +} + +.custom-toolbar .dropdowns select { + padding: 8px 12px; + border-radius: 11px; + font-size: 10px; + background-color: #555555; + color: #ffffff; + cursor: pointer; + transition: border-color 0.2s; +} + +.custom-toolbar .dropdowns select:hover { + border-color: #999; +} + +.right-section, +.view-switcher { + background-color: #717171; + height: 48px; + border-radius: 11px; + justify-items: center; + align-items: center; +} + +.custom-toolbar .navigation-controls .handleWeek button { + background-color: #717171; + height: 30px; + width: 30px; + margin-bottom: 3.5px; +} + +.view-change, +.right-section { + background-color: #717171; + height: 48px; + padding: 0 8px; + border-radius: 11px; + justify-items: center; +} + +.right-section .datepicker-box { + color: #000000; + background-color: #c6c6c6; + height: 36px; + border-radius: 11px; + font-size: 12px; + align-self: center; +} + +.datepicker { + text-align: center; + height: 30px; +} + +.datepicker-box { + z-index: 5; +} diff --git a/src/components/custom-toolbar.tsx b/src/components/custom-toolbar.tsx new file mode 100644 index 0000000..36c8fff --- /dev/null +++ b/src/components/custom-toolbar.tsx @@ -0,0 +1,260 @@ +import React, { useState, useEffect } from 'react'; +import './custom-toolbar.css'; +import { Button } from '@/components/ui/button'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import { NavigateAction } from 'react-big-calendar'; + +interface CustomToolbarProps { + //Aktuell angezeigtes Datum + date: Date; + //Aktuelle Ansicht + view: 'month' | 'week' | 'day' | 'agenda' | 'work_week'; + + onNavigate: (action: NavigateAction, newDate?: Date) => void; + //Ansichtwechsel + onView: (newView: 'month' | 'week' | 'day' | 'agenda' | 'work_week') => void; +} + +const CustomToolbar: React.FC = ({ + date, + view, + onNavigate, + onView, +}) => { + //ISO-Wochennummer eines Datums ermitteln + const getISOWeek = (date: Date): number => { + const tmp = new Date(date.getTime()); + //Datum so verschieben, dass der nächste Donnerstag erreicht wird (ISO: Woche beginnt am Montag) + tmp.setDate(tmp.getDate() + 4 - (tmp.getDay() || 7)); + const yearStart = new Date(tmp.getFullYear(), 0, 1); + const weekNo = Math.ceil( + ((tmp.getTime() - yearStart.getTime()) / 86400000 + 1) / 7, + ); + return weekNo; + }; + + //ISO-Wochenjahr eines Datums ermitteln + const getISOWeekYear = (date: Date): number => { + const tmp = new Date(date.getTime()); + tmp.setDate(tmp.getDate() + 4 - (tmp.getDay() || 7)); + return tmp.getFullYear(); + }; + + //Ermittlung der Anzahl der Wochen im Jahr + const getISOWeeksInYear = (year: number): number => { + const d = new Date(year, 11, 31); + const week = getISOWeek(d); + return week === 1 ? getISOWeek(new Date(year, 11, 24)) : week; + }; + + const getDateOfISOWeek = (week: number, year: number): Date => { + const jan1 = new Date(year, 0, 1); + const dayOfWeek = jan1.getDay(); + const isoDayOfWeek = dayOfWeek === 0 ? 7 : dayOfWeek; + let firstMonday: Date; + if (isoDayOfWeek <= 4) { + //1. Januar gehört zur ersten ISO-Woche (Montag dieser Woche bestimmen) + firstMonday = new Date(year, 0, 1 - isoDayOfWeek + 1); + } else { + //Ansonsten liegt der erste Montag in der darauffolgenden Woche + firstMonday = new Date(year, 0, 1 + (8 - isoDayOfWeek)); + } + firstMonday.setDate(firstMonday.getDate() + (week - 1) * 7); + return firstMonday; + }; + + //Lokaler State für Woche und ISO-Wochenjahr (statt des reinen Kalenderjahrs) + const [selectedWeek, setSelectedWeek] = useState(getISOWeek(date)); + const [selectedYear, setSelectedYear] = useState( + getISOWeekYear(date), + ); + + //Auswahl aktualisieren, wenn sich die Prop "date" ändert + useEffect(() => { + setSelectedWeek(getISOWeek(date)); + setSelectedYear(getISOWeekYear(date)); + }, [date]); + + //Start (Montag) und Ende (Sonntag) der aktuell angezeigten Woche berechnen + const weekStartDate = getDateOfISOWeek(selectedWeek, selectedYear); + const weekEndDate = new Date(weekStartDate); + weekEndDate.setDate(weekStartDate.getDate() + 6); + + //Ansichtwechsel + const handleViewChange = (newView: 'month' | 'week' | 'day' | 'agenda') => { + onView(newView); + }; + + //Today-Button aktualisiert das Datum im DatePicker auf das heutige + const handleToday = () => { + const today = new Date(); + setSelectedDate(today); + setSelectedWeek(getISOWeek(today)); + setSelectedYear(getISOWeekYear(today)); + onNavigate('TODAY', today); + }; + + //Pfeiltaste nach Vorne + const handleNext = () => { + let newDate: Date; + if (view === 'day' || view === 'agenda') { + newDate = new Date(date); + newDate.setDate(newDate.getDate() + 1); + } else if (view === 'week') { + let newWeek = selectedWeek + 1; + let newYear = selectedYear; + if (newWeek > getISOWeeksInYear(selectedYear)) { + newYear = selectedYear + 1; + newWeek = 1; + } + setSelectedWeek(newWeek); + setSelectedYear(newYear); + newDate = getDateOfISOWeek(newWeek, newYear); + } else if (view === 'month') { + newDate = new Date(date.getFullYear(), date.getMonth() + 1, 1); + } else { + newDate = new Date(date); + } + //Datum im DatePicker aktualisieren + setSelectedDate(newDate); + onNavigate('DATE', newDate); + }; + + //Pfeiltaste nach Hinten + const handlePrev = () => { + let newDate: Date; + if (view === 'day' || view === 'agenda') { + newDate = new Date(date); + newDate.setDate(newDate.getDate() - 1); + } else if (view === 'week') { + let newWeek = selectedWeek - 1; + let newYear = selectedYear; + if (newWeek < 1) { + newYear = selectedYear - 1; + newWeek = getISOWeeksInYear(newYear); + } + setSelectedWeek(newWeek); + setSelectedYear(newYear); + newDate = getDateOfISOWeek(newWeek, newYear); + } else if (view === 'month') { + newDate = new Date(date.getFullYear(), date.getMonth() - 1, 1); + } else { + newDate = new Date(date); + } + //Datum im DatePicker aktualisieren + setSelectedDate(newDate); + onNavigate('DATE', newDate); + }; + + const [selectedDate, setSelectedDate] = useState(new Date()); + + const handleDateChange = (date: Date | null) => { + setSelectedDate(date); + if (date) { + if (view === 'week') { + const newWeek = getISOWeek(date); + const newYear = getISOWeekYear(date); + setSelectedWeek(newWeek); + setSelectedYear(newYear); + const newDate = getDateOfISOWeek(newWeek, newYear); + onNavigate('DATE', newDate); + } else if (view === 'day') { + onNavigate('DATE', date); + } else if (view === 'month') { + const newDate = new Date(date.getFullYear(), date.getMonth(), 1); + onNavigate('DATE', newDate); + } else if (view === 'agenda') { + onNavigate('DATE', date); + } + } + }; + + return ( +
+
+
+ + + + +
+
+ +
+
+
+ + +
+
+ +
+
+ +
+ +
+
+
+ ); +}; + +export default CustomToolbar; diff --git a/src/components/custom-ui/app-sidebar.tsx b/src/components/custom-ui/app-sidebar.tsx new file mode 100644 index 0000000..ef01ca9 --- /dev/null +++ b/src/components/custom-ui/app-sidebar.tsx @@ -0,0 +1,131 @@ +'use client'; + +import React from 'react'; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from '@/components/custom-ui/sidebar'; + +import { ChevronDown } from 'lucide-react'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; + +import Logo from '@/components/misc/logo'; + +import Link from 'next/link'; + +import { + Star, + CalendarDays, + User, + Users, + CalendarClock, + CalendarPlus, +} from 'lucide-react'; + +const items = [ + { + title: 'Calendar', + url: '#', + icon: CalendarDays, + }, + { + title: 'Friends', + url: '#', + icon: User, + }, + { + title: 'Groups', + url: '#', + icon: Users, + }, + { + title: 'Events', + url: '/events', + icon: CalendarClock, + }, +]; + +export function AppSidebar() { + return ( + <> + + + + + + + + + + + + {' '} + + Favorites + + + + + + + + + + + + + {items.map((item) => ( + + + + + + {item.title} + + + + + ))} + + + + + + + + + New Event + + + + + + + + ); +} diff --git a/src/components/custom-ui/event-list-entry.tsx b/src/components/custom-ui/event-list-entry.tsx new file mode 100644 index 0000000..197649b --- /dev/null +++ b/src/components/custom-ui/event-list-entry.tsx @@ -0,0 +1,68 @@ +import { Card } from '@/components/ui/card'; +import Logo from '@/components/misc/logo'; +import { Label } from '@/components/ui/label'; +import Link from 'next/link'; +import zod from 'zod/v4'; +import { EventSchema } from '@/app/api/event/validation'; + +type EventListEntryProps = zod.output; + +export default function EventListEntry({ + title, + id, + start_time, + end_time, + location, +}: EventListEntryProps) { + const formatDate = (isoString?: string) => { + if (!isoString) return '-'; + return new Date(isoString).toLocaleDateString(); + }; + const formatTime = (isoString?: string) => { + if (!isoString) return '-'; + return new Date(isoString).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); + }; + return ( + + +
+
+ +
+
+

{title}

+
+
+
+ + +
+
+ + +
+ {location && ( +
+ + +
+ )} +
+
+
+ + ); +} diff --git a/src/components/custom-ui/labeled-input.tsx b/src/components/custom-ui/labeled-input.tsx index ea26e51..23601d9 100644 --- a/src/components/custom-ui/labeled-input.tsx +++ b/src/components/custom-ui/labeled-input.tsx @@ -1,5 +1,9 @@ -import { Input } from '@/components/ui/input'; +import { Input, Textarea } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import React from 'react'; +import { Button } from '../ui/button'; +import { Eye, EyeOff } from 'lucide-react'; +import { cn } from '@/lib/utils'; export default function LabeledInput({ type, @@ -7,31 +11,64 @@ export default function LabeledInput({ placeholder, value, name, + variantSize = 'default', autocomplete, error, ...rest }: { - type: 'text' | 'email' | 'password'; + type: 'text' | 'email' | 'password' | 'file'; label: string; placeholder?: string; value?: string; name?: string; + variantSize?: 'default' | 'big' | 'textarea'; autocomplete?: string; error?: string; } & React.InputHTMLAttributes) { + const [passwordVisible, setPasswordVisible] = React.useState(false); + return (
+ {variantSize === 'textarea' ? ( +