Compare commits

...

30 commits

Author SHA1 Message Date
77122eb634 feat(calendar): add calendar to home page
Some checks failed
tests / Tests (pull_request) Has been cancelled
docker-build / docker (pull_request) Has been cancelled
container-scan / Container Scan (pull_request) Has been cancelled
2025-06-26 10:57:31 +02:00
998ce5af93 feat(calendar): enhance calendar error handlick and loading 2025-06-26 10:37:32 +02:00
29b6e60fc7 feat(calendar): add calendar database integration and drag and drop 2025-06-26 10:37:28 +02:00
fa287ee9f7 style: format code 2025-06-26 10:37:10 +02:00
4a520d4612 feat: Calendar Layout and Function Update 2025-06-26 10:37:06 +02:00
d940a3d1ab feat: Custom Calendar Toolbar
Add Custom Calendar Toolbar
2025-06-26 10:36:59 +02:00
a35ece005f feat: Calendar Update 2025-06-26 10:35:28 +02:00
91bd8df5c7 feat: Basic Calendar without any functions 2025-06-26 10:34:45 +02:00
1a332a9373 Merge pull request 'fix(deps): revert nextjs to latest stable version' (#104)
All checks were successful
container-scan / Container Scan (push) Successful in 6m35s
docker-build / docker (push) Successful in 9m7s
tests / Tests (push) Successful in 4m59s
Reviewed-on: #104
Reviewed-by: Semir <semirtalke@gmail.com>
2025-06-26 07:47:38 +00:00
5f3484094e chore(deps): update node.js to 5340cbf
Some checks failed
container-scan / Container Scan (push) Failing after 25m37s
docker-build / docker (push) Failing after 20m7s
tests / Tests (push) Failing after 20m29s
2025-06-25 22:11:48 +00:00
5a44332d78 chore(deps): update node.js to 7704dc1
Some checks failed
container-scan / Container Scan (push) Failing after 11m42s
docker-build / docker (push) Successful in 2m5s
tests / Tests (push) Successful in 8m38s
2025-06-25 19:01:32 +00:00
45f3531a2a fix(deps): revert nextjs to latest stable version
All checks were successful
container-scan / Container Scan (pull_request) Successful in 11m52s
docker-build / docker (pull_request) Successful in 13m26s
tests / Tests (pull_request) Successful in 23m19s
2025-06-25 20:13:17 +02:00
0e24f7917b Merge pull request 'test: integrate testing library' (#56)
All checks were successful
container-scan / Container Scan (push) Successful in 6m45s
docker-build / docker (push) Successful in 9m7s
tests / Tests (push) Successful in 4m41s
Reviewed-on: #56
Reviewed-by: Maximilian Liebmann <lima@noreply.git.dominikstahl.dev>
2025-06-25 13:09:05 +00:00
781e25909d test: integrate cypress
All checks were successful
container-scan / Container Scan (pull_request) Successful in 2m46s
docker-build / docker (pull_request) Successful in 6m20s
tests / Tests (pull_request) Successful in 4m16s
2025-06-25 14:47:55 +02:00
7e65ac35a8 fix(deps): update dependency next to v15.4.0-canary.96
All checks were successful
container-scan / Container Scan (push) Successful in 5m7s
docker-build / docker (push) Successful in 1m27s
2025-06-25 12:01:25 +00:00
3a4695bc03 Merge pull request 'feat: create sidebar and header' (#103)
All checks were successful
container-scan / Container Scan (push) Successful in 2m44s
docker-build / docker (push) Successful in 6m40s
Reviewed-on: #103
Reviewed-by: Dominik <mail@dominikstahl.dev>
2025-06-25 10:39:17 +00:00
8bee6ede3f refactor: remove unused imports from notification button and user dropdown components
All checks were successful
container-scan / Container Scan (pull_request) Successful in 5m1s
docker-build / docker (pull_request) Successful in 5m31s
2025-06-25 12:08:19 +02:00
1d9ab84047 feat: enhance header with notification buttons and user dropdown
- Updated header component to include notification buttons with icons.
- Introduced a new NavUser component for user-related actions in the sidebar.
- Added NotificationDot component for visual notification indicators.
- Created UserCard component to display user information.
- Implemented UserDropdown for user settings and logout functionality.
- Added Avatar component for user images with fallback support.
- Refactored Sheet and Tooltip components for consistency and improved styling.
- Introduced QueryProvider for managing React Query context.
- Updated SidebarProvider to use custom sidebar implementation.
- Enhanced mobile detection hook for better responsiveness.
- Updated dependencies in yarn.lock for new features and fixes.

feat: remove dot
2025-06-25 12:08:19 +02:00
9225d8435a fix: add new Logos for equal hight in Sidebar 2025-06-25 12:08:19 +02:00
a6f74e0c22 feat: add Radix UI components and implement sidebar functionality
- Added new Radix UI components: Dialog, Tooltip, Separator, and updated existing components.
- Introduced a Sidebar component with collapsible functionality and mobile responsiveness.
- Implemented a custom hook `useIsMobile` to manage mobile state.
- Updated package dependencies in package.json and yarn.lock for new components.
- Created utility components such as Button, Skeleton, and Input for consistent styling.

feat: add AppSidebar component with collapsible functionality and sidebar menu

- Introduced AppSidebar component for a customizable sidebar layout.
- Implemented collapsible sections using Radix UI's Collapsible component.
- Added sidebar menu items with icons and links for navigation.
- Created Sidebar UI components including SidebarHeader, SidebarFooter, and SidebarMenu.
- Integrated ThemePicker for theme selection within the sidebar.
- Updated sidebar styles and layout for better responsiveness.

chore: add @radix-ui/react-collapsible dependency

- Added @radix-ui/react-collapsible package to manage collapsible UI elements.
2025-06-25 12:08:19 +02:00
21eff651e8 fix(deps): update dependency next to v15.4.0-canary.95
Some checks failed
container-scan / Container Scan (push) Failing after 4m10s
docker-build / docker (push) Successful in 1m27s
2025-06-25 00:01:36 +00:00
d62e954348 chore(deps): update dependency @types/node to v22.15.33
All checks were successful
container-scan / Container Scan (push) Successful in 4m19s
docker-build / docker (push) Successful in 1m19s
2025-06-24 17:01:52 +00:00
2889424bfb fix(deps): update dependency next to v15.4.0-canary.94
All checks were successful
container-scan / Container Scan (push) Successful in 4m11s
docker-build / docker (push) Successful in 1m23s
2025-06-24 00:01:37 +00:00
3ee0dcf950 fix(deps): update dependency next to v15.4.0-canary.93
All checks were successful
container-scan / Container Scan (push) Successful in 3m35s
docker-build / docker (push) Successful in 1m4s
2025-06-23 11:01:37 +00:00
c98a72f2f1 Merge pull request 'feat(api): implement missing user update and delete endpoints' (#102)
All checks were successful
container-scan / Container Scan (push) Successful in 4m58s
docker-build / docker (push) Successful in 7m17s
Reviewed-on: #102
Reviewed-by: Maximilian Liebmann <lima@noreply.git.dominikstahl.dev>
2025-06-23 09:37:13 +00:00
29f2a01ac6
style: format code
All checks were successful
container-scan / Container Scan (pull_request) Successful in 3m10s
docker-build / docker (pull_request) Successful in 7m25s
2025-06-23 10:45:56 +02:00
4cf5ce26ff
feat(api): implement DELETE method for /api/user/me endpoint
Some checks failed
container-scan / Container Scan (pull_request) Failing after 32s
docker-build / docker (pull_request) Successful in 7m22s
2025-06-23 10:44:26 +02:00
16b878a2e9
feat(api): stricter user data api types checking 2025-06-23 10:40:28 +02:00
280fa57e45
feat(api): implement /api/user/me/password endpoint
Some checks failed
container-scan / Container Scan (pull_request) Failing after 33s
docker-build / docker (pull_request) Failing after 5m11s
add an endpoint to allow the user to change his password
2025-06-23 10:00:19 +02:00
be1502a4ac fix(deps): update dependency next to v15.4.0-canary.92
Some checks failed
container-scan / Container Scan (push) Failing after 4m35s
docker-build / docker (push) Successful in 1m30s
2025-06-23 00:00:52 +00:00
64 changed files with 9303 additions and 384 deletions

6
.env.test Normal file
View file

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

34
.github/workflows/tests.yml vendored Normal file
View file

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

6
.gitignore vendored
View file

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

View file

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

View file

@ -1,4 +1,4 @@
FROM node:22-alpine@sha256:41e4389f3d988d2ed55392df4db1420ad048ae53324a8e2b7c6d19508288107e
FROM node:22-alpine@sha256:5340cbfc2df14331ab021555fdd9f83f072ce811488e705b0e736b11adeec4bb
WORKDIR /app

16
cypress.config.ts Normal file
View file

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

12
cypress/e2e/auth-user.ts Normal file
View file

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

View file

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

45
cypress/e2e/login.cy.ts Normal file
View file

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

29
cypress/e2e/seed.ts Normal file
View file

@ -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();

View file

@ -0,0 +1,62 @@
/// <reference types="cypress" />
// ***********************************************
// 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<void>
// drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
// }
// }
// }
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<JQuery<HTMLElement>>;
getBySelLike(
selector: string,
...args: any[]
): Chainable<JQuery<HTMLElement>>;
}
}
}
export {};

View file

@ -0,0 +1,14 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>Components App</title>
<!-- Used by Next.js to inject CSS. -->
<div id="__next_css__DO_NOT_USE__"></div>
</head>
<body>
<div data-cy-root></div>
</body>
</html>

View file

@ -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 <reference path="./component" /> at the top of your spec.
declare global {
namespace Cypress {
interface Chainable {
mount: typeof mount;
}
}
}
Cypress.Commands.add('mount', mount);
// Example use:
// cy.mount(<MyComponent />)

17
cypress/support/e2e.ts Normal file
View file

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

View file

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

View file

@ -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,38 +31,49 @@
"@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-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.91",
"date-fns": "^4.1.0",
"lucide-react": "^0.515.0",
"next": "15.3.4",
"next-auth": "^5.0.0-beta.25",
"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-dom": "^19.0.0",
"react-error-boundary": "^6.0.0",
"react-hook-form": "^7.56.4",
"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",
"@types/node": "22.15.33",
"@types/react": "19.1.8",
"@types/react-big-calendar": "^1",
"@types/react-dom": "19.1.6",
"@types/swagger-ui-react": "5",
"@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",

View file

@ -0,0 +1,14 @@
'use client';
import Calendar from '@/components/calendar';
import { useGetApiUserMe } from '@/generated/api/user/user';
export default function Home() {
const { data } = useGetApiUserMe();
return (
<div className='max-h-full'>
<Calendar userId={data?.data.user?.id} height='calc(100svh - 50px - (var(--spacing) * 2 * 5))' />
</div>
);
}

23
src/app/(main)/layout.tsx Normal file
View file

@ -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 (
<>
<SidebarProviderWrapper defaultOpen={defaultOpen}>
<AppSidebar></AppSidebar>
<Header>{children}</Header>
</SidebarProviderWrapper>
</>
);
}

View file

@ -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,19 +192,27 @@ 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, {

View file

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

View file

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

View file

@ -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'],
});
}

View file

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

View file

@ -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'],
});
}

View file

@ -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',
});

View file

@ -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(),
})

View file

@ -33,6 +33,7 @@
--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);
@ -55,6 +56,8 @@
--card: var(--neutral-800);
--sidebar-width-icon: 32px;
/* ------------------- */
--foreground: oklch(0.13 0.028 261.692);
@ -95,17 +98,17 @@
--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);
@ -155,6 +158,7 @@
--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);
@ -278,6 +282,7 @@
--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);
@ -339,17 +344,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%);

View file

@ -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 (
<div className='flex flex-col items-center justify-center h-screen'>
<div className='absolute top-4 right-4'>{<ThemePicker />}</div>
<div>
<h1>
Hello{' '}
{isLoading ? 'Loading...' : data?.data.user?.name || 'Unknown User'}
</h1>
<RedirectButton redirectUrl='/logout' buttonText='Logout' />
<RedirectButton redirectUrl='/settings' buttonText='Settings' />
</div>
</div>
);
}

View file

@ -2,7 +2,8 @@ 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 { SessionProvider } from 'next-auth/react';
export const metadata: Metadata = {
title: 'MeetUp',
@ -56,7 +57,9 @@ export default function RootLayout({
enableSystem
disableTransitionOnChange
>
<QueryProvider>{children}</QueryProvider>
<SessionProvider>
<QueryProvider>{children}</QueryProvider>
</SessionProvider>
</ThemeProvider>
</body>
</html>

View file

@ -33,7 +33,10 @@ export default async function LoginPage() {
</div>
<div className='mt-auto mb-auto'>
<Card className='w-[350px] max-w-screen;'>
<CardHeader className='grid place-items-center'>
<CardHeader
className='grid place-items-center'
data-cy='login-header'
>
<Logo colorType='colored' logoType='secondary'></Logo>
</CardHeader>
<CardContent className='gap-6 flex flex-col items-center'>
@ -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()}
/>
))}
</CardContent>

View file

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

View file

@ -0,0 +1,17 @@
<svg width="402" height="100" viewBox="0 0 402 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M234.931 38.9532C235.883 38.9532 236.684 39.3271 237.335 40.0751C237.987 40.8231 238.312 41.7437 238.312 42.8369C238.312 44.8361 236.815 46.2891 234.796 46.2891L171.031 46.3754C170.129 46.3754 169.378 46.0014 168.777 45.2535C168.126 44.563 167.8 43.7 167.8 42.6643C167.8 41.6286 168.126 40.7656 168.777 40.0751C169.378 39.3847 170.129 39.0395 171.031 39.0395L234.931 38.9532Z" fill="white"/>
<path d="M104.461 93.0499C103.487 93.0499 102.666 92.7092 102 92.0278C101.333 91.3463 101 90.5077 101 89.5117V42.5722C101 41.1569 101.487 39.9513 102.461 38.9554C103.436 37.907 104.615 37.3829 105.999 37.3829H108.538C109.563 37.3829 110.512 37.6974 111.383 38.3264C112.255 38.9554 112.871 39.7416 113.229 40.6852L130.689 85.1873L148.149 40.6852C148.559 39.7416 149.174 38.9554 149.995 38.3264C150.866 37.6974 151.815 37.3829 152.841 37.3829H155.379C156.763 37.3829 157.943 37.907 158.917 38.9554C159.891 39.9513 160.378 41.1569 160.378 42.5722V89.5117C160.378 90.5077 160.045 91.3463 159.378 92.0278C158.712 92.7092 157.891 93.0499 156.917 93.0499C155.943 93.0499 155.097 92.7092 154.379 92.0278C153.712 91.3463 153.379 90.5077 153.379 89.5117V44.8523L135.612 89.7476C135.201 90.7436 134.56 91.556 133.689 92.185C132.817 92.7616 131.817 93.0499 130.689 93.0499C129.561 93.0499 128.561 92.7616 127.689 92.185C126.818 91.556 126.177 90.7436 125.767 89.7476L107.999 44.8523V89.5117C107.999 90.5077 107.64 91.3463 106.922 92.0278C106.256 92.7092 105.435 93.0499 104.461 93.0499Z" fill="white"/>
<path d="M234.931 61.2199C235.883 61.2199 236.684 61.5939 237.335 62.3419C237.987 63.0899 238.312 64.0105 238.312 65.1037C238.312 67.1029 236.815 68.5559 234.796 68.5559L171.031 68.6422C170.129 68.6422 169.378 68.2682 168.777 67.5202C168.126 66.8298 167.8 65.9667 167.8 64.9311C167.8 63.8954 168.126 63.0324 168.777 62.3419C169.378 61.6515 170.129 61.3062 171.031 61.3062L234.931 61.2199Z" fill="#C1830D"/>
<path d="M234.931 85.6277C235.883 85.6277 236.684 86.0016 237.335 86.7496C237.987 87.4976 238.312 88.4182 238.312 89.5114C238.312 91.5106 236.815 92.9636 234.796 92.9636L171.031 93.0499C170.129 93.0499 169.378 92.6759 168.777 91.928C168.126 91.2375 167.8 90.3745 167.8 89.3388C167.8 88.3031 168.126 87.4401 168.777 86.7496C169.378 86.0592 170.129 85.714 171.031 85.714L234.931 85.6277Z" fill="white"/>
<path d="M267.963 93.0499C266.995 93.0499 266.181 92.7068 265.519 92.0205C264.857 91.3342 264.526 90.4896 264.526 89.4866V44.1136H248.943C248.026 44.1136 247.262 43.7969 246.651 43.1634C246.04 42.5299 245.734 41.738 245.734 40.7878C245.734 39.8376 246.04 39.0458 246.651 38.4123C247.262 37.726 248.026 37.3829 248.943 37.3829H286.983C287.9 37.3829 288.664 37.726 289.275 38.4123C289.937 39.0458 290.268 39.8376 290.268 40.7878C290.268 41.738 289.937 42.5299 289.275 43.1634C288.664 43.7969 287.9 44.1136 286.983 44.1136H271.477V89.4866C271.477 90.4896 271.12 91.3342 270.407 92.0205C269.745 92.7068 268.931 93.0499 267.963 93.0499Z" fill="white"/>
<path d="M338.498 3.30273C339.9 3.29168 341.204 4.05009 342.504 5.35547C344.287 7.14689 347.48 10.3467 350.621 13.4922L358.434 21.3037L358.456 21.3252L358.476 21.3486C359.393 22.4377 359.772 23.5909 359.703 24.6758C359.635 25.7528 359.129 26.695 358.402 27.375C356.958 28.7257 354.528 29.1152 352.683 27.4297L352.669 27.417L352.655 27.4033C350.775 25.4925 347.992 22.7063 345.677 20.3965C344.519 19.2419 343.48 18.2064 342.729 17.46C342.722 17.4528 342.715 17.4456 342.708 17.4385V74.9961C342.708 78.7908 342.028 82.1326 340.646 85.0029L340.647 85.0039C339.33 87.7947 337.557 90.1328 335.328 92.0059L335.32 92.0117C333.107 93.8166 330.654 95.1582 327.965 96.0342C325.293 96.9042 322.639 97.3428 320.005 97.3428C317.371 97.3428 314.716 96.9042 312.045 96.0342C309.355 95.1582 306.902 93.8166 304.689 92.0117L304.682 92.0059C302.455 90.1348 300.659 87.8002 299.29 85.0137L299.287 85.0068L299.283 84.999C297.955 82.13 297.302 78.7894 297.302 74.9961V43.5576C297.302 42.4529 297.649 41.4822 298.35 40.6836L298.494 40.5264C299.284 39.6526 300.283 39.1983 301.45 39.1982C302.613 39.1982 303.609 39.6498 304.397 40.5176C305.245 41.3395 305.675 42.3674 305.675 43.5576V75.2344C305.675 78.4181 306.366 81.0359 307.705 83.126C309.12 85.2003 310.917 86.7601 313.102 87.8184H313.101C315.307 88.8345 317.606 89.3408 320.005 89.3408C322.403 89.3408 324.702 88.8342 326.908 87.8184L327.312 87.6143C329.312 86.5634 330.947 85.0716 332.226 83.1289L332.228 83.126C333.618 81.0358 334.335 78.4183 334.335 75.2344V17.3291C333.656 18.0124 332.73 18.9438 331.672 20.0059C329.454 22.232 326.655 25.0349 324.336 27.3389L324.326 27.3486L324.315 27.3584C320.384 30.9544 315.024 25.7542 318.168 21.7617L318.193 21.7285L318.224 21.6982C321.362 18.5764 323.629 16.2939 326.017 13.8896C328.405 11.4851 330.915 8.95892 334.54 5.35254C335.811 4.08795 337.1 3.31376 338.498 3.30273Z" fill="#5770FF" stroke="#5770FF" stroke-width="1.39574"/>
<path d="M377.073 39.2316C381.146 39.2316 384.597 39.7797 387.4 40.9054H387.399C390.214 41.961 392.474 43.418 394.146 45.295C395.846 47.0923 397.064 49.0974 397.785 51.3077L397.924 51.7179C398.597 53.7677 398.935 55.8467 398.935 57.9523C398.935 60.1981 398.55 62.4136 397.785 64.5958C397.109 66.6659 395.999 68.5781 394.466 70.3322L394.154 70.6818C392.481 72.5093 390.216 73.9697 387.395 75.0812L387.388 75.0841C384.589 76.152 381.143 76.672 377.073 76.672H363.345V93.3468C363.345 94.5647 362.897 95.6139 362.018 96.4503C361.2 97.2792 360.179 97.6974 359 97.6974C357.813 97.6973 356.786 97.2729 355.965 96.4318C355.145 95.5914 354.736 94.548 354.736 93.3468V43.6642C354.736 42.463 355.145 41.4196 355.965 40.5792C356.779 39.6913 357.804 39.2317 359 39.2316H377.073ZM363.345 68.295H376.36C380.271 68.295 383.177 67.7578 385.147 66.7521C387.166 65.6658 388.491 64.3569 389.203 62.8497C389.956 61.2025 390.325 59.5717 390.326 57.9523C390.326 56.2735 389.954 54.643 389.203 53.0548C388.493 51.5512 387.172 50.2695 385.157 49.2374L385.145 49.2306C383.179 48.1733 380.274 47.6095 376.36 47.6095H363.345V68.295Z" fill="#5770FF" stroke="#5770FF" stroke-width="1.39574"/>
<rect x="7.14288" y="39.2857" width="75" height="53.5714" fill="#C1830D"/>
<path d="M3.57144 15.6832C3.57144 14.9114 4.19713 14.2857 4.96896 14.2857H84.3168C85.0886 14.2857 85.7143 14.9114 85.7143 15.6832V32.1428H3.57144V15.6832Z" fill="#5770FF"/>
<rect x="2.09627" y="12.8106" width="85.0932" height="85.0932" rx="2.09627" stroke="white" stroke-width="4.19255"/>
<line y1="35.0155" x2="89.2857" y2="35.0155" stroke="white" stroke-width="1.39752"/>
<rect x="19.2546" y="1.39752" width="9.53118" height="20.1191" rx="2.79503" fill="#C1830D" stroke="white" stroke-width="2.79503"/>
<rect x="58.5403" y="1.39752" width="9.53118" height="20.1191" rx="2.79503" fill="#C1830D" stroke="white" stroke-width="2.79503"/>
<path d="M84.6845 32.1428C85.9796 32.1428 87.0703 32.5641 87.9565 33.4066C88.8426 34.2492 89.2857 35.2862 89.2857 36.5176V95.528C89.2857 96.7594 88.8426 97.7964 87.9565 98.6389C87.0703 99.5463 85.9796 100 84.6845 100H43.6824C42.4554 100 41.4329 99.5787 40.6149 98.7362C39.7288 97.9584 39.2857 96.9863 39.2857 95.8197C39.2857 94.6531 39.7288 93.6809 40.6149 92.9032C41.4329 92.1254 42.4554 91.7366 43.6824 91.7366H79.981V70.2517H58.7396C57.5126 70.2517 56.4901 69.8304 55.6721 68.9879C54.786 68.2102 54.3429 67.238 54.3429 66.0714C54.3429 64.9048 54.786 63.9326 55.6721 63.1549C56.4901 62.3772 57.5126 61.9883 58.7396 61.9883H79.981V40.4062L43.6824 40.4062C42.4554 40.4062 41.4329 39.985 40.6149 39.1424C39.7288 38.3647 39.2857 37.3925 39.2857 36.2259C39.2857 35.0593 39.7288 34.0872 40.6149 33.3094C41.4329 32.5317 42.4554 32.1428 43.6824 32.1428H84.6845Z" fill="white"/>
<path d="M4.60123 100C3.30607 100 2.21541 99.5788 1.32924 98.7362C0.443082 97.8937 0 96.8567 0 95.6253V36.6148C0 35.3834 0.443082 34.3465 1.32924 33.5039C2.21541 32.5966 3.30607 32.1429 4.60123 32.1429H45.6033C46.8303 32.1429 47.8528 32.5642 48.6708 33.4067C49.5569 34.1844 50 35.1566 50 36.3232C50 37.4898 49.5569 38.462 48.6708 39.2397C47.8528 40.0174 46.8303 40.4063 45.6033 40.4063H9.3047V61.8911H30.5461C31.7731 61.8911 32.7956 62.3124 33.6136 63.155C34.4997 63.9327 34.9428 64.9049 34.9428 66.0715C34.9428 67.2381 34.4997 68.2102 33.6136 68.988C32.7956 69.7657 31.7731 70.1546 30.5461 70.1546H9.3047V91.7366H45.6033C46.8303 91.7366 47.8528 92.1579 48.6708 93.0004C49.5569 93.7782 50 94.7503 50 95.9169C50 97.0835 49.5569 98.0557 48.6708 98.8334C47.8528 99.6112 46.8303 100 45.6033 100H4.60123Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 8.3 KiB

View file

@ -0,0 +1,17 @@
<svg width="402" height="100" viewBox="0 0 402 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M234.931 38.9532C235.883 38.9532 236.684 39.3271 237.335 40.0751C237.987 40.8231 238.312 41.7437 238.312 42.8369C238.312 44.8361 236.815 46.2891 234.796 46.2891L171.031 46.3754C170.129 46.3754 169.378 46.0014 168.777 45.2535C168.126 44.563 167.8 43.7 167.8 42.6643C167.8 41.6286 168.126 40.7656 168.777 40.0751C169.378 39.3847 170.129 39.0395 171.031 39.0395L234.931 38.9532Z" fill="black"/>
<path d="M104.461 93.0499C103.487 93.0499 102.666 92.7092 102 92.0278C101.333 91.3463 101 90.5077 101 89.5117V42.5722C101 41.1569 101.487 39.9513 102.461 38.9554C103.436 37.907 104.615 37.3829 105.999 37.3829H108.538C109.563 37.3829 110.512 37.6974 111.383 38.3264C112.255 38.9554 112.871 39.7416 113.229 40.6852L130.689 85.1873L148.149 40.6852C148.559 39.7416 149.174 38.9554 149.995 38.3264C150.866 37.6974 151.815 37.3829 152.841 37.3829H155.379C156.763 37.3829 157.943 37.907 158.917 38.9554C159.891 39.9513 160.378 41.1569 160.378 42.5722V89.5117C160.378 90.5077 160.045 91.3463 159.378 92.0278C158.712 92.7092 157.891 93.0499 156.917 93.0499C155.943 93.0499 155.097 92.7092 154.379 92.0278C153.712 91.3463 153.379 90.5077 153.379 89.5117V44.8523L135.612 89.7476C135.201 90.7436 134.56 91.556 133.689 92.185C132.817 92.7616 131.817 93.0499 130.689 93.0499C129.561 93.0499 128.561 92.7616 127.689 92.185C126.818 91.556 126.177 90.7436 125.767 89.7476L107.999 44.8523V89.5117C107.999 90.5077 107.64 91.3463 106.922 92.0278C106.256 92.7092 105.435 93.0499 104.461 93.0499Z" fill="black"/>
<path d="M234.931 61.2199C235.883 61.2199 236.684 61.5939 237.335 62.3419C237.987 63.0899 238.312 64.0105 238.312 65.1037C238.312 67.1029 236.815 68.5559 234.796 68.5559L171.031 68.6422C170.129 68.6422 169.378 68.2682 168.777 67.5202C168.126 66.8298 167.8 65.9667 167.8 64.9311C167.8 63.8954 168.126 63.0324 168.777 62.3419C169.378 61.6515 170.129 61.3062 171.031 61.3062L234.931 61.2199Z" fill="#E69D11"/>
<path d="M234.931 85.6277C235.883 85.6277 236.684 86.0016 237.335 86.7496C237.987 87.4976 238.312 88.4182 238.312 89.5114C238.312 91.5106 236.815 92.9636 234.796 92.9636L171.031 93.0499C170.129 93.0499 169.378 92.6759 168.777 91.928C168.126 91.2375 167.8 90.3745 167.8 89.3388C167.8 88.3031 168.126 87.4401 168.777 86.7496C169.378 86.0592 170.129 85.714 171.031 85.714L234.931 85.6277Z" fill="black"/>
<path d="M267.963 93.0499C266.995 93.0499 266.181 92.7068 265.519 92.0205C264.857 91.3342 264.526 90.4896 264.526 89.4866V44.1136H248.943C248.026 44.1136 247.262 43.7969 246.651 43.1634C246.04 42.5299 245.734 41.738 245.734 40.7878C245.734 39.8376 246.04 39.0458 246.651 38.4123C247.262 37.726 248.026 37.3829 248.943 37.3829H286.983C287.9 37.3829 288.664 37.726 289.275 38.4123C289.937 39.0458 290.268 39.8376 290.268 40.7878C290.268 41.738 289.937 42.5299 289.275 43.1634C288.664 43.7969 287.9 44.1136 286.983 44.1136H271.477V89.4866C271.477 90.4896 271.12 91.3342 270.407 92.0205C269.745 92.7068 268.931 93.0499 267.963 93.0499Z" fill="black"/>
<path d="M338.498 3.30273C339.9 3.29168 341.204 4.05009 342.504 5.35547C344.287 7.14689 347.48 10.3467 350.621 13.4922L358.434 21.3037L358.456 21.3252L358.476 21.3486C359.393 22.4377 359.772 23.5909 359.703 24.6758C359.635 25.7528 359.129 26.695 358.402 27.375C356.958 28.7257 354.528 29.1152 352.683 27.4297L352.669 27.417L352.655 27.4033C350.775 25.4925 347.992 22.7063 345.677 20.3965C344.519 19.2419 343.48 18.2064 342.729 17.46C342.722 17.4528 342.715 17.4456 342.708 17.4385V74.9961C342.708 78.7908 342.028 82.1326 340.646 85.0029L340.647 85.0039C339.33 87.7947 337.557 90.1328 335.328 92.0059L335.32 92.0117C333.107 93.8166 330.654 95.1582 327.965 96.0342C325.293 96.9042 322.639 97.3428 320.005 97.3428C317.371 97.3428 314.716 96.9042 312.045 96.0342C309.355 95.1582 306.902 93.8166 304.689 92.0117L304.682 92.0059C302.455 90.1348 300.659 87.8002 299.29 85.0137L299.287 85.0068L299.283 84.999C297.955 82.13 297.302 78.7894 297.302 74.9961V43.5576C297.302 42.4529 297.649 41.4822 298.35 40.6836L298.494 40.5264C299.284 39.6526 300.283 39.1983 301.45 39.1982C302.613 39.1982 303.609 39.6498 304.397 40.5176C305.245 41.3395 305.675 42.3674 305.675 43.5576V75.2344C305.675 78.4181 306.366 81.0359 307.705 83.126C309.12 85.2003 310.917 86.7601 313.102 87.8184H313.101C315.307 88.8345 317.606 89.3408 320.005 89.3408C322.403 89.3408 324.702 88.8342 326.908 87.8184L327.312 87.6143C329.312 86.5634 330.947 85.0716 332.226 83.1289L332.228 83.126C333.618 81.0358 334.335 78.4183 334.335 75.2344V17.3291C333.656 18.0124 332.73 18.9438 331.672 20.0059C329.454 22.232 326.655 25.0349 324.336 27.3389L324.326 27.3486L324.315 27.3584C320.384 30.9544 315.024 25.7542 318.168 21.7617L318.193 21.7285L318.224 21.6982C321.362 18.5764 323.629 16.2939 326.017 13.8896C328.405 11.4851 330.915 8.95892 334.54 5.35254C335.811 4.08795 337.1 3.31376 338.498 3.30273Z" fill="#4154C0" stroke="#4154C0" stroke-width="1.39574"/>
<path d="M377.073 39.2316C381.146 39.2316 384.597 39.7797 387.4 40.9054H387.399C390.214 41.961 392.474 43.418 394.146 45.295C395.846 47.0923 397.064 49.0974 397.785 51.3077L397.924 51.7179C398.597 53.7677 398.935 55.8467 398.935 57.9523C398.935 60.1981 398.55 62.4136 397.785 64.5958C397.109 66.6659 395.999 68.5781 394.466 70.3322L394.154 70.6818C392.481 72.5093 390.216 73.9697 387.395 75.0812L387.388 75.0841C384.589 76.152 381.143 76.672 377.073 76.672H363.345V93.3468C363.345 94.5647 362.897 95.6139 362.018 96.4503C361.2 97.2792 360.179 97.6974 359 97.6974C357.813 97.6973 356.786 97.2729 355.965 96.4318C355.145 95.5914 354.736 94.548 354.736 93.3468V43.6642C354.736 42.463 355.145 41.4196 355.965 40.5792C356.779 39.6913 357.804 39.2317 359 39.2316H377.073ZM363.345 68.295H376.36C380.271 68.295 383.177 67.7578 385.147 66.7521C387.166 65.6658 388.491 64.3569 389.203 62.8497C389.956 61.2025 390.325 59.5717 390.326 57.9523C390.326 56.2735 389.954 54.643 389.203 53.0548C388.493 51.5512 387.172 50.2695 385.157 49.2374L385.145 49.2306C383.179 48.1733 380.274 47.6095 376.36 47.6095H363.345V68.295Z" fill="#4154C0" stroke="#4154C0" stroke-width="1.39574"/>
<rect x="7.14288" y="39.2857" width="75" height="53.5714" fill="#E69D11"/>
<path d="M3.57144 15.6832C3.57144 14.9114 4.19713 14.2857 4.96896 14.2857H84.3168C85.0886 14.2857 85.7143 14.9114 85.7143 15.6832V32.1428H3.57144V15.6832Z" fill="#4154C0"/>
<rect x="2.09627" y="12.8106" width="85.0932" height="85.0932" rx="2.09627" stroke="black" stroke-width="4.19255"/>
<line y1="35.0155" x2="89.2857" y2="35.0155" stroke="black" stroke-width="1.39752"/>
<rect x="19.2546" y="1.39752" width="9.53118" height="20.1191" rx="2.79503" fill="#E69D11" stroke="black" stroke-width="2.79503"/>
<rect x="58.5403" y="1.39752" width="9.53118" height="20.1191" rx="2.79503" fill="#E69D11" stroke="black" stroke-width="2.79503"/>
<path d="M84.6845 32.1428C85.9796 32.1428 87.0703 32.5641 87.9565 33.4066C88.8426 34.2492 89.2857 35.2862 89.2857 36.5176V95.528C89.2857 96.7594 88.8426 97.7964 87.9565 98.6389C87.0703 99.5463 85.9796 100 84.6845 100H43.6824C42.4554 100 41.4329 99.5787 40.6149 98.7362C39.7288 97.9584 39.2857 96.9863 39.2857 95.8197C39.2857 94.6531 39.7288 93.6809 40.6149 92.9032C41.4329 92.1254 42.4554 91.7366 43.6824 91.7366H79.981V70.2517H58.7396C57.5126 70.2517 56.4901 69.8304 55.6721 68.9879C54.786 68.2102 54.3429 67.238 54.3429 66.0714C54.3429 64.9048 54.786 63.9326 55.6721 63.1549C56.4901 62.3772 57.5126 61.9883 58.7396 61.9883H79.981V40.4062L43.6824 40.4062C42.4554 40.4062 41.4329 39.985 40.6149 39.1424C39.7288 38.3647 39.2857 37.3925 39.2857 36.2259C39.2857 35.0593 39.7288 34.0872 40.6149 33.3094C41.4329 32.5317 42.4554 32.1428 43.6824 32.1428H84.6845Z" fill="black"/>
<path d="M4.60123 100C3.30607 100 2.21541 99.5788 1.32924 98.7362C0.443082 97.8937 0 96.8567 0 95.6253V36.6148C0 35.3834 0.443082 34.3465 1.32924 33.5039C2.21541 32.5966 3.30607 32.1429 4.60123 32.1429H45.6033C46.8303 32.1429 47.8528 32.5642 48.6708 33.4067C49.5569 34.1844 50 35.1566 50 36.3232C50 37.4898 49.5569 38.462 48.6708 39.2397C47.8528 40.0174 46.8303 40.4063 45.6033 40.4063H9.3047V61.8911H30.5461C31.7731 61.8911 32.7956 62.3124 33.6136 63.155C34.4997 63.9327 34.9428 64.9049 34.9428 66.0715C34.9428 67.2381 34.4997 68.2102 33.6136 68.988C32.7956 69.7657 31.7731 70.1546 30.5461 70.1546H9.3047V91.7366H45.6033C46.8303 91.7366 47.8528 92.1579 48.6708 93.0004C49.5569 93.7782 50 94.7503 50 95.9169C50 97.0835 49.5569 98.0557 48.6708 98.8334C47.8528 99.6112 46.8303 100 45.6033 100H4.60123Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 8.3 KiB

View file

@ -0,0 +1,10 @@
<svg width="90" height="100" viewBox="0 0 90 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="7.14285" y="39.2857" width="75" height="53.5714" fill="#C1830D"/>
<path d="M3.57141 15.6832C3.57141 14.9114 4.1971 14.2857 4.96893 14.2857H84.3168C85.0886 14.2857 85.7143 14.9114 85.7143 15.6832V32.1429H3.57141V15.6832Z" fill="#5770FF"/>
<rect x="2.09627" y="12.8106" width="85.0932" height="85.0932" rx="2.09627" stroke="white" stroke-width="4.19255"/>
<line y1="35.0156" x2="89.2857" y2="35.0156" stroke="white" stroke-width="1.39752"/>
<rect x="19.2546" y="1.39752" width="9.53118" height="20.1191" rx="2.79503" fill="#C1830D" stroke="white" stroke-width="2.79503"/>
<rect x="58.5403" y="1.39752" width="9.53118" height="20.1191" rx="2.79503" fill="#C1830D" stroke="white" stroke-width="2.79503"/>
<path d="M84.6844 32.1429C85.9796 32.1429 87.0702 32.5642 87.9564 33.4067C88.8426 34.2492 89.2856 35.2862 89.2856 36.5176V95.5281C89.2856 96.7595 88.8426 97.7965 87.9564 98.639C87.0702 99.5464 85.9796 100 84.6844 100H43.6824C42.4554 100 41.4329 99.5788 40.6149 98.7362C39.7287 97.9585 39.2856 96.9863 39.2856 95.8197C39.2856 94.6531 39.7287 93.681 40.6149 92.9032C41.4329 92.1255 42.4554 91.7366 43.6824 91.7366H79.9809V70.2518H58.7396C57.5126 70.2518 56.4901 69.8305 55.6721 68.988C54.7859 68.2102 54.3428 67.2381 54.3428 66.0715C54.3428 64.9049 54.7859 63.9327 55.6721 63.155C56.4901 62.3772 57.5126 61.9884 58.7396 61.9884H79.9809V40.4063L43.6824 40.4063C42.4554 40.4063 41.4329 39.985 40.6149 39.1425C39.7287 38.3647 39.2856 37.3926 39.2856 36.226C39.2856 35.0594 39.7287 34.0872 40.6149 33.3095C41.4329 32.5318 42.4554 32.1429 43.6824 32.1429H84.6844Z" fill="white"/>
<path d="M4.60123 100C3.30607 100 2.21541 99.5787 1.32924 98.7362C0.443082 97.8936 0 96.8567 0 95.6252V36.6148C0 35.3834 0.443082 34.3464 1.32924 33.5039C2.21541 32.5965 3.30607 32.1429 4.60123 32.1429H45.6033C46.8303 32.1429 47.8528 32.5641 48.6708 33.4067C49.5569 34.1844 50 35.1566 50 36.3232C50 37.4898 49.5569 38.4619 48.6708 39.2397C47.8528 40.0174 46.8303 40.4063 45.6033 40.4063H9.3047V61.8911H30.5461C31.7731 61.8911 32.7956 62.3124 33.6136 63.1549C34.4997 63.9327 34.9428 64.9048 34.9428 66.0714C34.9428 67.238 34.4997 68.2102 33.6136 68.9879C32.7956 69.7657 31.7731 70.1545 30.5461 70.1545H9.3047V91.7366H45.6033C46.8303 91.7366 47.8528 92.1579 48.6708 93.0004C49.5569 93.7781 50 94.7503 50 95.9169C50 97.0835 49.5569 98.0557 48.6708 98.8334C47.8528 99.6111 46.8303 100 45.6033 100H4.60123Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -0,0 +1,10 @@
<svg width="90" height="100" viewBox="0 0 90 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="7.14285" y="39.2857" width="75" height="53.5714" fill="#E69D11"/>
<path d="M3.57141 15.6832C3.57141 14.9114 4.1971 14.2857 4.96893 14.2857H84.3168C85.0886 14.2857 85.7143 14.9114 85.7143 15.6832V32.1429H3.57141V15.6832Z" fill="#4154C0"/>
<rect x="2.09627" y="12.8106" width="85.0932" height="85.0932" rx="2.09627" stroke="black" stroke-width="4.19255"/>
<line y1="35.0156" x2="89.2857" y2="35.0156" stroke="black" stroke-width="1.39752"/>
<rect x="19.2546" y="1.39752" width="9.53118" height="20.1191" rx="2.79503" fill="#E69D11" stroke="black" stroke-width="2.79503"/>
<rect x="58.5403" y="1.39752" width="9.53118" height="20.1191" rx="2.79503" fill="#E69D11" stroke="black" stroke-width="2.79503"/>
<path d="M84.6844 32.1429C85.9796 32.1429 87.0702 32.5642 87.9564 33.4067C88.8426 34.2492 89.2856 35.2862 89.2856 36.5176V95.5281C89.2856 96.7595 88.8426 97.7965 87.9564 98.639C87.0702 99.5464 85.9796 100 84.6844 100H43.6824C42.4554 100 41.4329 99.5788 40.6149 98.7362C39.7287 97.9585 39.2856 96.9863 39.2856 95.8197C39.2856 94.6531 39.7287 93.681 40.6149 92.9032C41.4329 92.1255 42.4554 91.7366 43.6824 91.7366H79.9809V70.2518H58.7396C57.5126 70.2518 56.4901 69.8305 55.6721 68.988C54.7859 68.2102 54.3428 67.2381 54.3428 66.0715C54.3428 64.9049 54.7859 63.9327 55.6721 63.155C56.4901 62.3772 57.5126 61.9884 58.7396 61.9884H79.9809V40.4063L43.6824 40.4063C42.4554 40.4063 41.4329 39.985 40.6149 39.1425C39.7287 38.3647 39.2856 37.3926 39.2856 36.226C39.2856 35.0594 39.7287 34.0872 40.6149 33.3095C41.4329 32.5318 42.4554 32.1429 43.6824 32.1429H84.6844Z" fill="black"/>
<path d="M4.60123 100C3.30607 100 2.21541 99.5787 1.32924 98.7362C0.443082 97.8936 0 96.8567 0 95.6252V36.6148C0 35.3834 0.443082 34.3464 1.32924 33.5039C2.21541 32.5965 3.30607 32.1429 4.60123 32.1429H45.6033C46.8303 32.1429 47.8528 32.5641 48.6708 33.4067C49.5569 34.1844 50 35.1566 50 36.3232C50 37.4898 49.5569 38.4619 48.6708 39.2397C47.8528 40.0174 46.8303 40.4063 45.6033 40.4063H9.3047V61.8911H30.5461C31.7731 61.8911 32.7956 62.3124 33.6136 63.1549C34.4997 63.9327 34.9428 64.9048 34.9428 66.0714C34.9428 67.238 34.4997 68.2102 33.6136 68.9879C32.7956 69.7657 31.7731 70.1545 30.5461 70.1545H9.3047V91.7366H45.6033C46.8303 91.7366 47.8528 92.1579 48.6708 93.0004C49.5569 93.7781 50 94.7503 50 95.9169C50 97.0835 49.5569 98.0557 48.6708 98.8334C47.8528 99.6111 46.8303 100 45.6033 100H4.60123Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -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<typeof Button>) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type='button' variant='outline_primary' {...props}>
{children}
<NotificationDot
dotVariant={dotVariant}
className='absolute ml-[30px] mt-[30px]'
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'></DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -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<HTMLButtonElement>) {
return (
<form
className='flex flex-col items-center w-full'
@ -22,6 +23,7 @@ export default function SSOLogin({
type='submit'
variant='secondary'
icon={faOpenid}
{...props}
>
Login with {providerDisplayName}
</IconButton>

244
src/components/calendar.tsx Normal file
View file

@ -0,0 +1,244 @@
'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 (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ resetErrorBoundary, error }) => (
<div className='flex flex-col items-center justify-center h-full'>
There was an error!
<p className='text-red-500'>
{typeof error === 'string'
? error
: error.errors
.map((e: $ZodIssue) => fromZodIssue(e).toString())
.join(', ')}
</p>
<Button onClick={() => resetErrorBoundary()}>Try again</Button>
</div>
)}
>
{userId ? (
<CalendarWithUserEvents userId={userId} height={height} />
) : (
<CalendarWithoutUserEvents height={height} />
)}
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
}
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<Date>(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 (
<DaDRBCalendar
localizer={localizer}
culture='de-DE'
defaultView='week'
components={{
toolbar: CustomToolbar,
}}
style={{
height: height,
}}
onView={setCurrentView}
view={currentView}
date={currentDate}
onNavigate={(date) => {
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<Date>(new Date());
return (
<DaDRBCalendar
localizer={localizer}
culture='de-DE'
defaultView='week'
style={{
height: height,
}}
components={{
toolbar: CustomToolbar,
}}
onView={setCurrentView}
view={currentView}
date={currentDate}
onNavigate={(date) => {
setCurrentDate(date);
}}
/>
);
}

View file

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

View file

@ -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<CustomToolbarProps> = ({
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<number>(getISOWeek(date));
const [selectedYear, setSelectedYear] = useState<number>(
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<Date | null>(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 (
<div
className='custom-toolbar'
style={{ display: 'flex', flexDirection: 'initial', gap: '8px' }}
>
<div className='view-change'>
<div className='view-switcher' style={{ display: 'flex', gap: '8px' }}>
<Button
//className='hover:bg-orange-600 hover:text-white'
type='submit'
variant='primary'
onClick={() => handleViewChange('month')}
size={'default'}
>
Month
</Button>
<Button
//className='hover:bg-orange-600 hover:text-white'
type='submit'
variant='primary'
onClick={() => handleViewChange('week')}
size={'default'}
>
Week
</Button>
<Button
//className='hover:bg-orange-600 hover:text-white'
type='submit'
variant='primary'
onClick={() => handleViewChange('day')}
size={'default'}
>
Day
</Button>
<Button
//className='hover:bg-orange-600 hover:text-white'
type='submit'
variant='primary'
onClick={() => handleViewChange('agenda')}
size={'default'}
>
Agenda
</Button>
</div>
</div>
<div
className='right-section'
style={{ display: 'flex', flexDirection: 'initial', gap: '8px' }}
>
<div
className='navigation-controls'
style={{ display: 'flex', gap: '8px' }}
>
<div className='handleWeek'>
<button onClick={handlePrev}>&lt;</button>
<button onClick={handleNext}>&gt;</button>
</div>
<div className='today'>
<Button
//className='hover:bg-orange-600 hover:text-white'
type='submit'
variant='secondary'
onClick={() => handleToday()}
size={'default'}
>
Today
</Button>
</div>
</div>
<div className='datepicker-box'>
<DatePicker
className='datepicker'
selected={selectedDate}
onChange={handleDateChange}
calendarStartDay={1}
locale='de-DE'
dateFormat='dd.MM.yyyy'
showWeekNumbers={true}
/>
</div>
</div>
</div>
);
};
export default CustomToolbar;

View file

@ -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: '#',
icon: CalendarClock,
},
];
export function AppSidebar() {
return (
<>
<Sidebar collapsible='icon' variant='sidebar'>
<SidebarHeader className='overflow-hidden'>
<Logo
colorType='colored'
logoType='combo'
height={50}
className='group-data-[collapsible=icon]:hidden min-w-[203px]'
></Logo>
<Logo
colorType='colored'
logoType='submark'
height={50}
className='group-data-[collapsible=]:hidden group-data-[mobile=true]/mobile:hidden'
></Logo>
</SidebarHeader>
<SidebarContent className='grid grid-rows-[auto_1fr_auto]'>
<Collapsible defaultOpen className='group/collapsible'>
<SidebarGroup>
<SidebarGroupLabel asChild>
<CollapsibleTrigger>
<span className='flex items-center gap-2 text-xl font-label text-neutral-100'>
<Star className='size-8' />{' '}
<span className='group-data-[collapsible=icon]:hidden'>
Favorites
</span>
</span>
<ChevronDown className='ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180 group-data-[collapsible=icon]:hidden text-nowrap whitespace-nowrap' />
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent />
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title} className='pt-2'>
<SidebarMenuButton asChild>
<Link href={item.url}>
<item.icon className='size-8' />
<span className='text-xl font-label group-data-[collapsible=icon]:hidden text-nowrap whitespace-nowrap'>
{item.title}
</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
<SidebarFooter>
<SidebarMenuItem className='pl-[8px]'>
<Link
href='/event/new'
className='flex items-center gap-2 text-xl font-label'
>
<CalendarPlus className='size-8' />
<span className='group-data-[collapsible=icon]:hidden text-nowrap whitespace-nowrap'>
New Event
</span>
</Link>
</SidebarMenuItem>
</SidebarFooter>
</SidebarContent>
</Sidebar>
</>
);
}

View file

@ -0,0 +1,725 @@
'use client';
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, VariantProps } from 'class-variance-authority';
import { PanelLeftIcon } from 'lucide-react';
import { useIsMobile } from '@/hooks/use-mobile';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Skeleton } from '@/components/ui/skeleton';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
const SIDEBAR_COOKIE_NAME = 'sidebar_state';
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = '16rem';
const SIDEBAR_WIDTH_MOBILE = '18rem';
const SIDEBAR_WIDTH_ICON = '4rem';
const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
type SidebarContextProps = {
state: 'expanded' | 'collapsed';
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error('useSidebar must be used within a SidebarProvider.');
}
return context;
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<'div'> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === 'function' ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? 'expanded' : 'collapsed';
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot='sidebar-wrapper'
style={
{
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
className,
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
function Sidebar({
side = 'left',
variant = 'sidebar',
collapsible = 'offcanvas',
className,
children,
...props
}: React.ComponentProps<'div'> & {
side?: 'left' | 'right';
variant?: 'sidebar' | 'floating' | 'inset';
collapsible?: 'offcanvas' | 'icon' | 'none';
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === 'none') {
return (
<div
data-slot='sidebar'
className={cn(
'bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col',
className,
)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar='sidebar'
data-slot='sidebar'
data-mobile='true'
className='bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden group/mobile'
style={
{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className='sr-only'>
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className='flex h-full w-full flex-col'>{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className='group peer text-sidebar-foreground hidden md:block'
data-state={state}
data-collapsible={state === 'collapsed' ? collapsible : ''}
data-variant={variant}
data-side={side}
data-slot='sidebar'
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot='sidebar-gap'
className={cn(
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
)}
/>
<div
data-slot='sidebar-container'
className={cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
className,
)}
{...props}
>
<div
data-sidebar='sidebar'
data-slot='sidebar-inner'
className='bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm'
>
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Button
data-sidebar='trigger'
data-slot='sidebar-trigger'
variant='muted'
size='icon'
className={cn('', className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className='sr-only'>Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar='rail'
data-slot='sidebar-rail'
aria-label='Toggle Sidebar'
tabIndex={-1}
onClick={toggleSidebar}
title='Toggle Sidebar'
className={cn(
'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex',
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
className,
)}
{...props}
/>
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
return (
<main
data-slot='sidebar-inset'
className={cn(
'bg-background relative flex w-full flex-1 flex-col',
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
className,
)}
{...props}
/>
);
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot='sidebar-input'
data-sidebar='input'
className={cn('bg-background h-8 w-full shadow-none', className)}
{...props}
/>
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='sidebar-header'
data-sidebar='header'
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='sidebar-footer'
data-sidebar='footer'
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
);
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot='sidebar-separator'
data-sidebar='separator'
className={cn('bg-sidebar-border mx-2 w-auto', className)}
{...props}
/>
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='sidebar-content'
data-sidebar='content'
className={cn(
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
className,
)}
{...props}
/>
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='sidebar-group'
data-sidebar='group'
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
{...props}
/>
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<'div'> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'div';
return (
<Comp
data-slot='sidebar-group-label'
data-sidebar='group-label'
className={cn(
'text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0 ml-[7.5px]',
className,
)}
{...props}
/>
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<'button'> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot='sidebar-group-action'
data-sidebar='group-action'
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
'after:absolute after:-inset-2 md:after:hidden',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
);
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='sidebar-group-content'
data-sidebar='group-content'
className={cn('w-full text-sm', className)}
{...props}
/>
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
return (
<ul
data-slot='sidebar-menu'
data-sidebar='menu'
className={cn('flex w-full min-w-0 flex-col gap-1', className)}
{...props}
/>
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot='sidebar-menu-item'
data-sidebar='menu-item'
className={cn('group/menu-item relative list-none', className)}
{...props}
/>
);
}
const sidebarMenuButtonVariants = cva(
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! p-0 ml-[15.5px] [&>span:last-child]:truncate [&>svg]:shrink-0',
{
variants: {
variant: {
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
outline:
'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',
},
size: {
default: 'h-8 text-sm',
sm: 'h-7 text-xs',
lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = 'default',
size = 'default',
tooltip,
className,
...props
}: React.ComponentProps<'button'> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : 'button';
const { isMobile, state } = useSidebar();
const button = (
<Comp
data-slot='sidebar-menu-button'
data-sidebar='menu-button'
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === 'string') {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side='right'
align='center'
hidden={state !== 'collapsed' || isMobile}
{...tooltip}
/>
</Tooltip>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<'button'> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot='sidebar-menu-action'
data-sidebar='menu-action'
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
'after:absolute after:-inset-2 md:after:hidden',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
showOnHover &&
'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0',
className,
)}
{...props}
/>
);
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='sidebar-menu-badge'
data-sidebar='menu-badge'
className={cn(
'text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none',
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<'div'> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
data-slot='sidebar-menu-skeleton'
data-sidebar='menu-skeleton'
className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
{...props}
>
{showIcon && (
<Skeleton
className='size-4 rounded-md'
data-sidebar='menu-skeleton-icon'
/>
)}
<Skeleton
className='h-4 max-w-(--skeleton-width) flex-1'
data-sidebar='menu-skeleton-text'
style={
{
'--skeleton-width': width,
} as React.CSSProperties
}
/>
</div>
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
return (
<ul
data-slot='sidebar-menu-sub'
data-sidebar='menu-sub'
className={cn(
'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
);
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<'li'>) {
return (
<li
data-slot='sidebar-menu-sub-item'
data-sidebar='menu-sub-item'
className={cn('group/menu-sub-item relative', className)}
{...props}
/>
);
}
function SidebarMenuSubButton({
asChild = false,
size = 'md',
isActive = false,
className,
...props
}: React.ComponentProps<'a'> & {
asChild?: boolean;
size?: 'sm' | 'md';
isActive?: boolean;
}) {
const Comp = asChild ? Slot : 'a';
return (
<Comp
data-slot='sidebar-menu-sub-button'
data-sidebar='menu-sub-button'
data-size={size}
data-active={isActive}
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
size === 'sm' && 'text-xs',
size === 'md' && 'text-sm',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
);
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};

View file

@ -48,13 +48,18 @@ function LoginFormElement({
});
return (
<form className='flex flex-col gap-5 w-full' onSubmit={onSubmit}>
<form
className='flex flex-col gap-5 w-full'
onSubmit={onSubmit}
data-cy='login-form'
>
<LabeledInput
type='text'
label='E-Mail or Username'
placeholder='What you are known as'
error={formState.errors.email?.message}
{...register('email')}
data-cy='email-input'
/>
<LabeledInput
type='password'
@ -62,9 +67,10 @@ function LoginFormElement({
placeholder="Let's hope you remember it"
error={formState.errors.password?.message}
{...register('password')}
data-cy='password-input'
/>
<div className='grid grid-rows-2 gap-2'>
<Button type='submit' variant='primary'>
<Button type='submit' variant='primary' data-cy='login-button'>
Login
</Button>
<Button
@ -74,6 +80,7 @@ function LoginFormElement({
formRef?.current?.reset();
setIsSignUp((v) => !v);
}}
data-cy='register-switch'
>
Sign Up
</Button>
@ -129,6 +136,7 @@ function RegisterFormElement({
ref={formRef}
className='flex flex-col gap-5 w-full'
onSubmit={onSubmit}
data-cy='register-form'
>
<LabeledInput
type='text'
@ -137,6 +145,7 @@ function RegisterFormElement({
autocomplete='given-name'
error={formState.errors.firstName?.message}
{...register('firstName')}
data-cy='first-name-input'
/>
<LabeledInput
type='text'
@ -145,6 +154,7 @@ function RegisterFormElement({
autocomplete='family-name'
error={formState.errors.lastName?.message}
{...register('lastName')}
data-cy='last-name-input'
/>
<LabeledInput
type='email'
@ -153,6 +163,7 @@ function RegisterFormElement({
autocomplete='email'
error={formState.errors.email?.message}
{...register('email')}
data-cy='email-input'
/>
<LabeledInput
type='text'
@ -161,6 +172,7 @@ function RegisterFormElement({
autocomplete='username'
error={formState.errors.username?.message}
{...register('username')}
data-cy='username-input'
/>
<LabeledInput
type='password'
@ -169,6 +181,7 @@ function RegisterFormElement({
autocomplete='new-password'
error={formState.errors.password?.message}
{...register('password')}
data-cy='password-input'
/>
<LabeledInput
type='password'
@ -177,9 +190,10 @@ function RegisterFormElement({
autocomplete='new-password'
error={formState.errors.confirmPassword?.message}
{...register('confirmPassword')}
data-cy='confirm-password-input'
/>
<div className='grid grid-rows-2 gap-2'>
<Button type='submit' variant='primary'>
<Button type='submit' variant='primary' data-cy='register-button'>
Sign Up
</Button>
<Button

View file

@ -0,0 +1,51 @@
import { SidebarTrigger } from '@/components/custom-ui/sidebar';
import { ThemePicker } from '@/components/misc/theme-picker';
import { NotificationButton } from '@/components/buttons/notification-button';
import { BellRing, Inbox } from 'lucide-react';
import UserDropdown from '@/components/misc/user-dropdown';
const items = [
{
title: 'Calendar',
url: '#',
icon: Inbox,
},
{
title: 'Friends',
url: '#',
icon: BellRing,
},
];
export default function Header({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<div className='w-full grid grid-rows-[50px_1fr] h-screen'>
<header className='border-b-1 grid-cols-[1fr_3fr_1fr] grid items-center px-2 shadow-md'>
<span className='flex justify-start'>
<SidebarTrigger variant='outline_primary' size='icon' />
</span>
<span className='flex justify-center'>Search</span>
<span className='flex gap-1 justify-end'>
<ThemePicker />
{items.map((item) => (
<NotificationButton
key={item.title}
variant='outline_primary'
dotVariant='hidden'
size='icon'
>
<item.icon />
</NotificationButton>
))}
<UserDropdown />
</span>
</header>
<main className='max-h-full overflow-y-auto p-5'>{children}</main>
</div>
);
}

View file

@ -90,6 +90,7 @@ export default function Logo({
return (
<Image
unoptimized
src={logoVar}
alt={alt || defaultAltText}
className={className}

View file

@ -0,0 +1,110 @@
'use client';
import {
BadgeCheck,
Bell,
ChevronsUpDown,
CreditCard,
LogOut,
Sparkles,
} from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@/components/custom-ui/sidebar';
export function NavUser({
user,
}: {
user: {
name: string;
email: string;
avatar: string;
};
}) {
const { isMobile } = useSidebar();
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size='lg'
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
>
<Avatar className='h-8 w-8 rounded-lg'>
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className='rounded-lg'>CN</AvatarFallback>
</Avatar>
<div className='grid flex-1 text-left text-sm leading-tight'>
<span className='truncate font-medium'>{user.name}</span>
<span className='truncate text-xs'>{user.email}</span>
</div>
<ChevronsUpDown className='ml-auto size-4' />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className='w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg'
side={isMobile ? 'bottom' : 'right'}
align='end'
sideOffset={4}
>
<DropdownMenuLabel className='p-0 font-normal'>
<div className='flex items-center gap-2 px-1 py-1.5 text-left text-sm'>
<Avatar className='h-8 w-8 rounded-lg'>
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className='rounded-lg'>CN</AvatarFallback>
</Avatar>
<div className='grid flex-1 text-left text-sm leading-tight'>
<span className='truncate font-medium'>{user.name}</span>
<span className='truncate text-xs'>{user.email}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<Sparkles />
Upgrade to Pro
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<BadgeCheck />
Account
</DropdownMenuItem>
<DropdownMenuItem>
<CreditCard />
Billing
</DropdownMenuItem>
<DropdownMenuItem>
<Bell />
Notifications
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
<LogOut />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}

View file

@ -0,0 +1,35 @@
import { cn } from '@/lib/utils';
import { cva, type VariantProps } from 'class-variance-authority';
import { CircleSmall } from 'lucide-react';
const dotVariants = cva('', {
variants: {
variant: {
neutral: 'fill-neutral-900',
active: 'fill-red-600 stroke-red-600',
hidden: 'hidden',
},
},
defaultVariants: {
variant: 'hidden',
},
});
function NotificationDot({
className,
dotVariant,
...props
}: {
className: string;
dotVariant: VariantProps<typeof dotVariants>['variant'];
}) {
return (
<CircleSmall
className={cn(dotVariants({ variant: dotVariant }), className)}
{...props}
/>
);
}
export type NDot = VariantProps<typeof dotVariants>['variant'];
export { NotificationDot, dotVariants };

View file

@ -0,0 +1,41 @@
import React from 'react';
import { ThemePicker } from '@/components/misc/theme-picker';
import { ThemeProvider } from '@/components/wrappers/theme-provider';
describe('<ThemePicker />', () => {
it('renders', () => {
cy.mount(<ThemePicker />);
});
it('toggle open and close', () => {
cy.mount(<ThemePicker />);
cy.getBySel('theme-picker').click();
cy.getBySel('theme-picker-content').should('exist');
cy.get('html').click();
cy.getBySel('theme-picker-content').should('not.exist');
});
it('enable dark mode', () => {
cy.mount(
<ThemeProvider>
<ThemePicker />
</ThemeProvider>,
);
cy.getBySel('theme-picker').click();
cy.getBySel('dark-theme').click();
cy.get('html').should('have.attr', 'data-theme', 'dark');
});
it('enable light mode', () => {
cy.mount(
<ThemeProvider>
<ThemePicker />
</ThemeProvider>,
);
cy.getBySel('theme-picker').click();
cy.getBySel('light-theme').click();
cy.get('html').should('have.attr', 'data-theme', 'light');
});
});

View file

@ -18,20 +18,26 @@ export function ThemePicker() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='outline_primary' size='icon'>
<Button variant='outline_primary' size='icon' data-cy='theme-picker'>
<Sun className='h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0' />
<Moon className='absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100' />
<span className='sr-only'>Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem onClick={() => setTheme('light')}>
<DropdownMenuContent align='end' data-cy='theme-picker-content'>
<DropdownMenuItem
onClick={() => setTheme('light')}
data-cy='light-theme'
>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>
<DropdownMenuItem onClick={() => setTheme('dark')} data-cy='dark-theme'>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>
<DropdownMenuItem
onClick={() => setTheme('system')}
data-cy='system-theme'
>
System
</DropdownMenuItem>
</DropdownMenuContent>

View file

@ -0,0 +1,29 @@
import { useGetApiUserMe } from '@/generated/api/user/user';
import { Avatar } from '@/components/ui/avatar';
import Image from 'next/image';
import { User } from 'lucide-react';
export default function UserCard() {
const { data } = useGetApiUserMe();
return (
<div className='w-full'>
<Avatar className='flex justify-center items-center'>
{data?.data.user.image ? (
<Image
className='border-2 rounded-md'
src={data?.data.user.image}
alt='Avatar'
width='50'
height='50'
/>
) : (
<User />
)}
</Avatar>
<div className='flex justify-center'>{data?.data.user.name}</div>
<div className='flex justify-center text-text-muted'>
{data?.data.user.email}
</div>
</div>
);
}

View file

@ -0,0 +1,52 @@
'use client';
import { Avatar } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useGetApiUserMe } from '@/generated/api/user/user';
import { ChevronDown, User } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import UserCard from '@/components/misc/user-card';
export default function UserDropdown() {
const { data } = useGetApiUserMe();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='outline_primary'>
<Avatar className='flex justify-center items-center'>
{data?.data.user.image ? (
<Image
src={data?.data.user.image}
alt='Avatar'
width='20'
height='20'
/>
) : (
<User />
)}
</Avatar>
<ChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem>
<UserCard />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>Settings</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Link href='/logout'>Logout</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -0,0 +1,930 @@
@charset "UTF-8";
.rbc-btn {
color: inherit;
font: inherit;
margin: 0;
}
button.rbc-btn {
overflow: visible;
text-transform: none;
-webkit-appearance: button;
-moz-appearance: button;
appearance: button;
cursor: pointer;
}
button[disabled].rbc-btn {
cursor: not-allowed;
}
button.rbc-input::-moz-focus-inner {
border: 0;
padding: 0;
}
.rbc-calendar {
-webkit-box-sizing: border-box;
box-sizing: border-box;
height: 100%;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-align: stretch;
-ms-flex-align: stretch;
align-items: stretch;
}
.rbc-m-b-negative-3 {
margin-bottom: -3px;
}
.rbc-h-full {
height: 100%;
}
.rbc-calendar *,
.rbc-calendar *:before,
.rbc-calendar *:after {
-webkit-box-sizing: inherit;
box-sizing: inherit;
}
.rbc-abs-full,
.rbc-row-bg {
overflow: hidden;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.rbc-ellipsis,
.rbc-show-more,
.rbc-row-segment .rbc-event-content,
.rbc-event-label {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rbc-rtl {
direction: rtl;
}
.rbc-off-range {
color: #999999;
}
.rbc-off-range-bg {
background: #e6e6e6;
}
.rbc-header {
overflow: hidden;
-webkit-box-flex: 1;
-ms-flex: 1 0 0%;
flex: 1 0 0%;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0 3px;
text-align: center;
vertical-align: middle;
font-weight: bold;
font-size: 90%;
min-height: 0;
border-bottom: 1px solid #ddd;
}
.rbc-header + .rbc-header {
border-left: 1px solid #c6c6c6; /*#ddd*/
}
.rbc-rtl .rbc-header + .rbc-header {
border-left-width: 0;
border-right: 1px solid #ddd;
}
.rbc-header > a,
.rbc-header > a:active,
.rbc-header > a:visited {
color: inherit;
text-decoration: none;
}
.rbc-button-link {
color: inherit;
background: none;
margin: 0;
padding: 0;
border: none;
cursor: pointer;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
}
.rbc-row-content {
position: relative;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
-webkit-user-select: none;
z-index: 4;
}
.rbc-row-content-scrollable {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
height: 100%;
}
.rbc-row-content-scrollable .rbc-row-content-scroll-container {
height: 100%;
overflow-y: scroll;
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
/* Hide scrollbar for Chrome, Safari and Opera */
}
.rbc-row-content-scrollable
.rbc-row-content-scroll-container::-webkit-scrollbar {
display: none;
}
.rbc-today {
background-color: #5770ff; /*#eaf6ff*/
}
/*Own changes 10*/
.rbc-allday-cell .rbc-row-bg .rbc-day-bg.rbc-today {
background-color: transparent !important;
/*border: none !important;*/
}
/*Own changes 10*/
/*Own changes 11*/
.rbc-time-header-cell .rbc-header:first-child.rbc-today {
border-top-left-radius: 11px !important;
}
.rbc-time-header-cell .rbc-header:last-child.rbc-today {
border-top-right-radius: 11px !important;
}
/*Own changes 11*/
.rbc-toolbar {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-ms-flex-wrap: wrap;
flex-wrap: wrap;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
margin-bottom: 10px;
font-size: 16px;
}
.rbc-toolbar .rbc-toolbar-label {
-webkit-box-flex: 1;
-ms-flex-positive: 1;
flex-grow: 1;
padding: 0 10px;
text-align: center;
/*Own changes 01*/
background-color: #717171;
color: #ffffff;
/*Own changes 01*/
}
.rbc-toolbar button {
color: #373a3c;
display: inline-block;
margin: 0;
text-align: center;
vertical-align: middle;
background: none;
background-image: none;
border: 1px solid #ccc;
padding: 0.375rem 1rem;
border-radius: 4px;
line-height: normal;
white-space: nowrap;
}
.rbc-toolbar button:active,
.rbc-toolbar button.rbc-active {
background-image: none;
-webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
background-color: #e6e6e6;
border-color: #adadad;
}
.rbc-toolbar button:active:hover,
.rbc-toolbar button:active:focus,
.rbc-toolbar button.rbc-active:hover,
.rbc-toolbar button.rbc-active:focus {
color: #373a3c;
background-color: #d4d4d4;
border-color: #8c8c8c;
}
.rbc-toolbar button:focus {
color: #373a3c;
background-color: #e6e6e6;
border-color: #adadad;
}
.rbc-toolbar button:hover {
color: #373a3c;
cursor: pointer;
background-color: #e6e6e6;
border-color: #adadad;
}
.rbc-btn-group {
display: inline-block;
white-space: nowrap;
}
.rbc-btn-group > button:first-child:not(:last-child) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
/*Own changes 02*/
background-color: #c6c6c6;
color: #000000;
/*Own changes 02*/
}
.rbc-btn-group > button:last-child:not(:first-child) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
/*Own changes 03*/
background-color: #c6c6c6;
color: #000000;
/*Own changes 03*/
}
.rbc-rtl .rbc-btn-group > button:first-child:not(:last-child) {
border-radius: 4px;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.rbc-rtl .rbc-btn-group > button:last-child:not(:first-child) {
border-radius: 4px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.rbc-btn-group > button:not(:first-child):not(:last-child) {
border-radius: 0;
/*Own changes 04*/
background-color: #c6c6c6;
color: #000000;
/*Own changes 04*/
}
.rbc-btn-group button + button {
margin-left: -1px;
}
.rbc-rtl .rbc-btn-group button + button {
margin-left: 0;
margin-right: -1px;
}
.rbc-btn-group + .rbc-btn-group,
.rbc-btn-group + button {
margin-left: 10px;
}
@media (max-width: 767px) {
.rbc-toolbar {
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
}
}
.rbc-event,
.rbc-day-slot .rbc-background-event {
border: none;
-webkit-box-sizing: border-box;
box-sizing: border-box;
-webkit-box-shadow: none;
box-shadow: none;
margin: 0;
padding: 2px 5px;
background-color: #3174ad;
border-radius: 5px;
color: #fff;
cursor: pointer;
width: 100%;
text-align: left;
}
.rbc-slot-selecting .rbc-event,
.rbc-slot-selecting .rbc-day-slot .rbc-background-event,
.rbc-day-slot .rbc-slot-selecting .rbc-background-event {
cursor: inherit;
pointer-events: none;
}
.rbc-event.rbc-selected,
.rbc-day-slot .rbc-selected.rbc-background-event {
background-color: #265985;
}
.rbc-event:focus,
.rbc-day-slot .rbc-background-event:focus {
outline: 5px auto #3b99fc;
}
.rbc-event-label {
font-size: 80%;
}
.rbc-event-overlaps {
-webkit-box-shadow: -1px 1px 5px 0px rgba(51, 51, 51, 0.5);
box-shadow: -1px 1px 5px 0px rgba(51, 51, 51, 0.5);
}
.rbc-event-continues-prior {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.rbc-event-continues-after {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.rbc-event-continues-earlier {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.rbc-event-continues-later {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.rbc-row {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: horizontal;
-webkit-box-direction: normal;
-ms-flex-direction: row;
flex-direction: row;
}
.rbc-row-segment {
padding: 0 1px 1px 1px;
}
.rbc-selected-cell {
background-color: rgba(0, 0, 0, 0.1);
}
.rbc-show-more {
background-color: rgba(255, 255, 255, 0.3);
z-index: 4;
font-weight: bold;
font-size: 85%;
height: auto;
line-height: normal;
color: #3174ad;
}
.rbc-show-more:hover,
.rbc-show-more:focus {
color: #265985;
}
.rbc-month-view {
position: relative;
border: 1px solid #ddd;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-flex: 1;
-ms-flex: 1 0 0px;
flex: 1 0 0;
width: 100%;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
-webkit-user-select: none;
height: 100%;
}
.rbc-month-header {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: horizontal;
-webkit-box-direction: normal;
-ms-flex-direction: row;
flex-direction: row;
}
.rbc-month-row {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
position: relative;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-flex: 1;
-ms-flex: 1 0 0px;
flex: 1 0 0;
-ms-flex-preferred-size: 0px;
flex-basis: 0px;
overflow: hidden;
height: 100%;
}
.rbc-month-row + .rbc-month-row {
border-top: 1px solid #ddd;
}
.rbc-date-cell {
-webkit-box-flex: 1;
-ms-flex: 1 1 0px;
flex: 1 1 0;
min-width: 0;
padding-right: 5px;
text-align: right;
}
.rbc-date-cell.rbc-now {
font-weight: bold;
}
.rbc-date-cell > a,
.rbc-date-cell > a:active,
.rbc-date-cell > a:visited {
color: inherit;
text-decoration: none;
}
.rbc-row-bg {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: horizontal;
-webkit-box-direction: normal;
-ms-flex-direction: row;
flex-direction: row;
-webkit-box-flex: 1;
-ms-flex: 1 0 0px;
flex: 1 0 0;
overflow: hidden;
right: 1px;
}
.rbc-day-bg {
-webkit-box-flex: 1;
-ms-flex: 1 0 0%;
flex: 1 0 0%;
}
.rbc-day-bg + .rbc-day-bg {
border-left: 1px solid #ddd;
}
.rbc-rtl .rbc-day-bg + .rbc-day-bg {
border-left-width: 0;
border-right: 1px solid #ddd;
}
.rbc-overlay {
position: absolute;
z-index: 5;
border: 1px solid #e5e5e5;
background-color: #fff;
-webkit-box-shadow: 0 5px 15px rgba(0, 0, 0, 0.25);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.25);
padding: 10px;
}
.rbc-overlay > * + * {
margin-top: 1px;
}
.rbc-overlay-header {
border-bottom: 1px solid #e5e5e5;
margin: -10px -10px 5px -10px;
padding: 2px 10px;
}
.rbc-agenda-view {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-flex: 1;
-ms-flex: 1 0 0px;
flex: 1 0 0;
overflow: auto;
}
.rbc-agenda-view table.rbc-agenda-table {
width: 100%;
border: 1px solid #ddd;
border-spacing: 0;
border-collapse: collapse;
}
.rbc-agenda-view table.rbc-agenda-table tbody > tr > td {
padding: 5px 10px;
vertical-align: top;
}
.rbc-agenda-view table.rbc-agenda-table .rbc-agenda-time-cell {
padding-left: 15px;
padding-right: 15px;
text-transform: lowercase;
}
.rbc-agenda-view table.rbc-agenda-table tbody > tr > td + td,
.rbc-agenda-view table.rbc-agenda-table tbody > tr > td.rbc-agenda-time-cell {
border-left: 1px solid #ddd;
}
.rbc-rtl .rbc-agenda-view table.rbc-agenda-table tbody > tr > td + td {
border-left-width: 0;
border-right: 1px solid #ddd;
}
.rbc-agenda-view table.rbc-agenda-table tbody > tr + tr {
border-top: 1px solid #ddd;
}
.rbc-agenda-view table.rbc-agenda-table thead > tr > th {
padding: 3px 5px;
text-align: left;
border-bottom: 1px solid #ddd;
}
.rbc-rtl .rbc-agenda-view table.rbc-agenda-table thead > tr > th {
text-align: right;
}
.rbc-agenda-time-cell {
text-transform: lowercase;
}
.rbc-agenda-time-cell .rbc-continues-after:after {
content: ' »';
}
.rbc-agenda-time-cell .rbc-continues-prior:before {
content: '« ';
}
.rbc-agenda-date-cell,
.rbc-agenda-time-cell {
white-space: nowrap;
}
.rbc-agenda-event-cell {
width: 100%;
}
.rbc-time-column {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
min-height: 100%;
/*Own changes 06*/
background-color: #383838;
/*Own changes 06*/
}
.rbc-time-column .rbc-timeslot-group {
-webkit-box-flex: 1;
-ms-flex: 1;
flex: 1;
}
.rbc-timeslot-group {
border-bottom: 1px solid #8d8d8d; /*#ddd*/
min-height: 40px;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-flow: column nowrap;
flex-flow: column nowrap;
}
.rbc-time-gutter,
.rbc-header-gutter {
-webkit-box-flex: 0;
-ms-flex: none;
flex: none;
/*Own changes 07*/
background-color: #8d8d8d;
/*Own changes 07*/
}
.rbc-label {
padding: 0 5px;
}
.rbc-day-slot {
position: relative;
}
.rbc-day-slot .rbc-events-container {
bottom: 0;
left: 0;
position: absolute;
right: 0;
margin-right: 10px;
top: 0;
}
.rbc-day-slot .rbc-events-container.rbc-rtl {
left: 10px;
right: 0;
}
.rbc-day-slot .rbc-event,
.rbc-day-slot .rbc-background-event {
border: 1px solid #265985;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
max-height: 100%;
min-height: 20px;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-flow: column wrap;
flex-flow: column wrap;
-webkit-box-align: start;
-ms-flex-align: start;
align-items: flex-start;
overflow: hidden;
position: absolute;
}
.rbc-day-slot .rbc-background-event {
opacity: 0.75;
}
.rbc-day-slot .rbc-event-label {
-webkit-box-flex: 0;
-ms-flex: none;
flex: none;
padding-right: 5px;
width: auto;
}
.rbc-day-slot .rbc-event-content {
width: 100%;
-webkit-box-flex: 1;
-ms-flex: 1 1 0px;
flex: 1 1 0;
word-wrap: break-word;
line-height: 1;
height: 100%;
min-height: 1em;
}
.rbc-day-slot .rbc-time-slot {
border-top: 1px solid #383838; /*#f7f7f7*/
}
.rbc-time-view-resources .rbc-time-gutter,
.rbc-time-view-resources .rbc-time-header-gutter {
position: sticky;
left: 0;
background-color: white;
border-right: 1px solid #ddd;
z-index: 10;
margin-right: -1px;
}
.rbc-time-view-resources .rbc-time-header {
overflow: hidden;
}
.rbc-time-view-resources .rbc-time-header-content {
min-width: auto;
-webkit-box-flex: 1;
-ms-flex: 1 0 0px;
flex: 1 0 0;
-ms-flex-preferred-size: 0px;
flex-basis: 0px;
}
.rbc-time-view-resources .rbc-time-header-cell-single-day {
display: none;
}
.rbc-time-view-resources .rbc-day-slot {
min-width: 140px;
}
.rbc-time-view-resources .rbc-header,
.rbc-time-view-resources .rbc-day-bg {
width: 140px;
-webkit-box-flex: 1;
-ms-flex: 1 1 0px;
flex: 1 1 0;
-ms-flex-preferred-size: 0 px;
flex-basis: 0 px;
}
.rbc-time-header-content + .rbc-time-header-content {
margin-left: -1px;
}
.rbc-time-slot {
-webkit-box-flex: 1;
-ms-flex: 1 0 0px;
flex: 1 0 0;
}
.rbc-time-slot.rbc-now {
font-weight: bold;
}
.rbc-day-header {
text-align: center;
}
.rbc-slot-selection {
z-index: 10;
position: absolute;
background-color: rgba(0, 0, 0, 0.5);
color: white;
font-size: 75%;
width: 100%;
padding: 3px;
}
.rbc-slot-selecting {
cursor: move;
}
.rbc-time-view {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-flex: 1;
-ms-flex: 1;
flex: 1;
width: 100%;
min-height: 0;
}
.rbc-time-view .rbc-time-gutter {
white-space: nowrap;
text-align: right;
}
.rbc-time-view .rbc-allday-cell {
-webkit-box-sizing: content-box;
box-sizing: content-box;
width: 100%;
height: 100%;
position: relative;
/*Own changes 05*/
background-color: #555555;
/*Own changes 05*/
}
.rbc-time-view .rbc-allday-cell + .rbc-allday-cell {
border-left: 1px solid #ddd;
}
.rbc-time-view .rbc-allday-events {
position: relative;
z-index: 4;
}
.rbc-time-view .rbc-row {
-webkit-box-sizing: border-box;
box-sizing: border-box;
min-height: 20px;
}
.rbc-time-header {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-flex: 0;
-ms-flex: 0 0 auto;
flex: 0 0 auto;
-webkit-box-orient: horizontal;
-webkit-box-direction: normal;
-ms-flex-direction: row;
flex-direction: row;
}
.rbc-rtl .rbc-time-header.rbc-overflowing {
border-right-width: 0;
border-left: 1px solid #ddd;
}
.rbc-time-header > .rbc-row:first-child {
border-bottom: 1px solid #ddd;
}
.rbc-time-header > .rbc-row.rbc-row-resource {
border-bottom: 1px solid #ddd;
}
.rbc-time-header-cell-single-day {
display: none;
}
.rbc-time-header-content {
-webkit-box-flex: 1;
-ms-flex: 1;
flex: 1;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
min-width: 0;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
border-left: 1px solid #ddd;
/*Own changes 08*/
background-color: #c6c6c6;
color: #000000;
border-top-left-radius: 11px;
border-top-right-radius: 11px;
/*Own changes 08*/
}
.rbc-rtl .rbc-time-header-content {
border-left-width: 0;
border-right: 1px solid #ddd;
}
.rbc-time-header-content > .rbc-row.rbc-row-resource {
border-bottom: 1px solid #ddd;
-ms-flex-negative: 0;
flex-shrink: 0;
}
.rbc-time-content {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-flex: 1;
-ms-flex: 1 0 0%;
flex: 1 0 0%;
-webkit-box-align: start;
-ms-flex-align: start;
align-items: flex-start;
width: 100%;
overflow-y: auto;
position: relative;
}
.rbc-time-header-content {
border-bottom: 2px solid #717171; /*#ddd*/
}
.rbc-time-column :last-child {
border-bottom: 0;
}
.rbc-time-content > .rbc-time-gutter {
-webkit-box-flex: 0;
-ms-flex: none;
flex: none;
/*Own changes 09*/
border-top-left-radius: 11px;
border-bottom-left-radius: 11px;
/*Own changes 09*/
}
.rbc-time-content > * + * > * {
border-left: 1px solid #c6c6c6; /*#ddd*/
}
.rbc-rtl .rbc-time-content > * + * > * {
border-left-width: 0;
border-right: 1px solid #ddd;
}
.rbc-time-content > .rbc-day-slot {
width: 100%;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
-webkit-user-select: none;
}
.rbc-current-time-indicator {
position: absolute;
z-index: 3;
left: 0;
right: 0;
height: 1px;
background-color: #74ad31;
pointer-events: none;
}
.rbc-resource-grouping.rbc-time-header-content {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
}
.rbc-resource-grouping .rbc-row .rbc-header {
width: 141px;
}
/*# sourceMappingURL=react-big-calendar.css.map */

View file

@ -0,0 +1,53 @@
'use client';
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '@/lib/utils';
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot='avatar'
className={cn(
'relative flex shrink-0 overflow-hidden rounded-md',
className,
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot='avatar-image'
className={cn('aspect-square size-full', className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot='avatar-fallback'
className={cn(
'bg-muted flex size-full items-center justify-center rounded-full',
className,
)}
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback };

View file

@ -0,0 +1,33 @@
'use client';
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot='collapsible' {...props} />;
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot='collapsible-trigger'
{...props}
/>
);
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot='collapsible-content'
{...props}
/>
);
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View file

@ -13,10 +13,13 @@ function Separator({
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot='separator-root'
data-slot='separator'
decorative={decorative}
orientation={orientation}
className={cn('shrink-0', className)}
className={cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className,
)}
{...props}
/>
);

139
src/components/ui/sheet.tsx Normal file
View file

@ -0,0 +1,139 @@
'use client';
import * as React from 'react';
import * as SheetPrimitive from '@radix-ui/react-dialog';
import { XIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot='sheet' {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot='sheet-trigger' {...props} />;
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot='sheet-close' {...props} />;
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot='sheet-portal' {...props} />;
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot='sheet-overlay'
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = 'right',
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: 'top' | 'right' | 'bottom' | 'left';
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot='sheet-content'
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
side === 'right' &&
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
side === 'left' &&
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
side === 'top' &&
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
side === 'bottom' &&
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
className,
)}
{...props}
>
{children}
<SheetPrimitive.Close className='ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none'>
<XIcon className='size-4' />
<span className='sr-only'>Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='sheet-header'
className={cn('flex flex-col gap-1.5 p-4', className)}
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='sheet-footer'
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
);
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot='sheet-title'
className={cn('text-foreground font-semibold', className)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot='sheet-description'
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

View file

@ -0,0 +1,13 @@
import { cn } from '@/lib/utils';
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='skeleton'
className={cn('bg-accent animate-pulse rounded-md', className)}
{...props}
/>
);
}
export { Skeleton };

View file

@ -0,0 +1,61 @@
'use client';
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@/lib/utils';
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot='tooltip-provider'
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot='tooltip' {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot='tooltip-trigger' {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot='tooltip-content'
sideOffset={sideOffset}
className={cn(
'bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className='bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]' />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View file

@ -0,0 +1,23 @@
'use client';
import React from 'react';
import { SidebarProvider } from '../custom-ui/sidebar';
export default function SidebarProviderWrapper({
defaultOpen,
children,
}: {
defaultOpen: boolean;
children: React.ReactNode;
}) {
const [open, setOpen] = React.useState(defaultOpen);
return (
<SidebarProvider
defaultOpen={defaultOpen}
open={open}
onOpenChange={setOpen}
>
{children}
</SidebarProvider>
);
}

21
src/hooks/use-mobile.ts Normal file
View file

@ -0,0 +1,21 @@
import * as React from 'react';
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener('change', onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener('change', onChange);
}, []);
return !!isMobile;
}

3717
src/lib/timezones.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -20,7 +20,8 @@
],
"paths": {
"@/*": ["./src/*"]
}
},
"types": ["node", "cypress", "@types/webpack-env"]
},
"ts-node": {
"compilerOptions": {

1982
yarn.lock

File diff suppressed because it is too large Load diff