Compare commits

...

55 commits

Author SHA1 Message Date
0c260820e2 feat: tempcommit 2025-06-27 09:56:19 +02:00
a308158ca7 feat: tempcommit 2025-06-27 09:56:17 +02:00
f658a95b16 feat: tempcommit 2025-06-27 09:18:01 +02:00
13a99e9dc4 feat: tempcommit 2025-06-27 09:18:01 +02:00
5d81288479 feat: Implement settings dropdown and page components
- Added `SettingsDropdown` component for selecting settings sections with icons and descriptions.
- Created `SettingsPage` component to manage user settings, including account details, notifications, calendar availability, privacy, and appearance.
- Introduced `SettingsSwitcher` for selecting options within settings.
- Integrated command and dialog components for improved user interaction.
- Updated `UserDropdown` to include links for settings and logout.
- Refactored button styles and card footer layout for consistency.
- Added popover functionality for dropdown menus.
- Updated dependencies in `yarn.lock` for new components.
2025-06-27 09:17:59 +02:00
6a5ad338ba 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-27 09:06:52 +02:00
1a9a299c9c Merge pull request 'chore(deps): update dependency prettier to v3.6.2' (#101)
All checks were successful
container-scan / Container Scan (push) Successful in 16m11s
docker-build / docker (push) Successful in 17m58s
tests / Tests (push) Successful in 14m51s
Reviewed-on: #101
Reviewed-by: Dominik <mail@dominikstahl.dev>
2025-06-27 06:24:01 +00:00
9f1f1e24e9
chore(deps): update dependency prettier to v3.6.2
Some checks failed
docker-build / docker (push) Waiting to run
tests / Tests (push) Waiting to run
container-scan / Container Scan (pull_request) Has been cancelled
docker-build / docker (pull_request) Has been cancelled
tests / Tests (pull_request) Has been cancelled
2025-06-27 08:12:25 +02:00
9045eedf9c Merge pull request 'chore(deps): pin dependency @types/react-big-calendar to 1.16.2' (#107)
All checks were successful
container-scan / Container Scan (push) Successful in 8m5s
docker-build / docker (push) Successful in 1m25s
tests / Tests (push) Successful in 6m12s
Reviewed-on: #107
Reviewed-by: Dominik <mail@dominikstahl.dev>
2025-06-27 05:39:43 +00:00
33ef6d3e98 chore(deps): pin dependency @types/react-big-calendar to 1.16.2
All checks were successful
container-scan / Container Scan (pull_request) Successful in 11m38s
tests / Tests (push) Successful in 10m58s
tests / Tests (pull_request) Successful in 13m3s
docker-build / docker (push) Successful in 11m0s
docker-build / docker (pull_request) Successful in 1m34s
2025-06-26 21:01:01 +00:00
6a73a41e89 Merge pull request 'chore(deps): update tailwindcss monorepo to v4.1.11' (#106)
All checks were successful
container-scan / Container Scan (push) Successful in 8m32s
docker-build / docker (push) Successful in 11m41s
tests / Tests (push) Successful in 7m4s
Reviewed-on: #106
Reviewed-by: Dominik <mail@dominikstahl.dev>
2025-06-26 20:36:39 +00:00
1ac7e69dda Merge pull request 'chore(deps): pin dependencies' (#100)
Some checks failed
docker-build / docker (push) Waiting to run
tests / Tests (push) Waiting to run
container-scan / Container Scan (push) Has been cancelled
Reviewed-on: #100
Reviewed-by: Dominik <mail@dominikstahl.dev>
2025-06-26 20:36:05 +00:00
ebd02f6022 Merge pull request 'fix(deps): update dependency lucide-react to ^0.523.0' (#84)
Some checks failed
docker-build / docker (push) Waiting to run
tests / Tests (push) Waiting to run
container-scan / Container Scan (push) Has been cancelled
Reviewed-on: #84
Reviewed-by: Dominik <mail@dominikstahl.dev>
2025-06-26 20:35:38 +00:00
0b91673dbe Merge pull request 'feat: create in app calendar' (#94)
Some checks failed
docker-build / docker (push) Waiting to run
container-scan / Container Scan (push) Has been cancelled
tests / Tests (push) Has been cancelled
Reviewed-on: #94
Reviewed-by: Dominik <mail@dominikstahl.dev>
2025-06-26 20:35:00 +00:00
a3e7224087 feat(calendar): add calendar to home page
All checks were successful
container-scan / Container Scan (pull_request) Successful in 8m13s
docker-build / docker (pull_request) Successful in 9m11s
tests / Tests (pull_request) Successful in 7m3s
2025-06-26 20:29:13 +02:00
758afb36b9 feat(calendar): enhance calendar error handlick and loading 2025-06-26 20:29:03 +02:00
fd7be58541 feat(calendar): add calendar database integration and drag and drop 2025-06-26 20:29:03 +02:00
3a96d0e259 style: format code 2025-06-26 20:28:37 +02:00
04b1b80e53 feat: Calendar Layout and Function Update 2025-06-26 20:28:37 +02:00
675a5fa5ff feat: Custom Calendar Toolbar
Add Custom Calendar Toolbar
2025-06-26 20:27:56 +02:00
1d721874b2 feat: Calendar Update 2025-06-26 20:27:06 +02:00
a6cc4d3284 feat: Basic Calendar without any functions 2025-06-26 20:26:28 +02:00
18aed86d60 fix(deps): update dependency lucide-react to ^0.523.0
All checks were successful
docker-build / docker (push) Successful in 10m34s
tests / Tests (push) Successful in 6m59s
container-scan / Container Scan (pull_request) Successful in 8m41s
docker-build / docker (pull_request) Successful in 1m32s
tests / Tests (pull_request) Successful in 6m18s
2025-06-26 18:01:40 +00:00
80cac65fc6 chore(deps): update tailwindcss monorepo to v4.1.11
All checks were successful
docker-build / docker (push) Successful in 10m35s
tests / Tests (push) Successful in 5m45s
container-scan / Container Scan (pull_request) Successful in 8m17s
docker-build / docker (pull_request) Successful in 1m39s
tests / Tests (pull_request) Successful in 5m43s
2025-06-26 18:01:14 +00:00
edee739812 chore(deps): pin dependencies
All checks were successful
docker-build / docker (push) Successful in 9m18s
tests / Tests (push) Successful in 5m39s
container-scan / Container Scan (pull_request) Successful in 6m48s
docker-build / docker (pull_request) Successful in 1m21s
tests / Tests (pull_request) Successful in 6m6s
2025-06-26 18:01:00 +00:00
412117b4dd Merge pull request 'feat: event creation functionality' (#99)
All checks were successful
container-scan / Container Scan (push) Successful in 7m35s
docker-build / docker (push) Successful in 10m46s
tests / Tests (push) Successful in 5m42s
Reviewed-on: #99
Reviewed-by: Dominik <mail@dominikstahl.dev>
2025-06-26 17:16:55 +00:00
cd7c62cb98 fix(deps): update dependency swagger-ui-react to v5.25.3
All checks were successful
container-scan / Container Scan (push) Successful in 10m45s
docker-build / docker (push) Successful in 1m56s
tests / Tests (push) Successful in 7m10s
2025-06-26 11:01:16 +00:00
42e1b69720 feat(events): add deletion button and style toaster for light mode
All checks were successful
container-scan / Container Scan (pull_request) Successful in 11m20s
docker-build / docker (pull_request) Successful in 11m30s
tests / Tests (pull_request) Successful in 8m39s
2025-06-26 12:56:53 +02:00
8bbb7e4c85 feat(events): refactor event and participant list entries to use schema validation 2025-06-26 10:17:25 +02:00
4a4d3b73d6 feat(events): add event creation page 2025-06-26 10:17:24 +02:00
a357a5a8d7 feat(event): add event view page 2025-06-26 10:17:24 +02:00
173be63a9c feat(events): add event edit page 2025-06-26 10:17:24 +02:00
abc4c063c3 feat(events): add event list lage 2025-06-26 10:17:24 +02:00
51fe42374d feat(events): add event creation form components 2025-06-26 10:17:24 +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
96 changed files with 13194 additions and 1026 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@sha256:9daea41366dfd1b72496bf3e8295eda215a6990c2dbe4f9ff4b8ba47342864fb
options: --user 1001
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Enable corepack
run: corepack enable
- name: Cypress run (e2e)
uses: https://github.com/cypress-io/github-action@v6
with:
build: yarn cypress:build
start: yarn cypress:start_server
e2e: true
wait-on: 'http://127.0.0.1:3000'
- name: Cypress run (component)
uses: https://github.com/cypress-io/github-action@v6
with:
component: true
install: false

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

View file

@ -66,7 +66,6 @@ This project is built with a modern tech stack:
yarn install
```
3. **Set up environment variables:**
- You will need to create an `AUTH_SECRET`. You can generate one using the following command:
```bash
npx auth secret
@ -97,7 +96,6 @@ This project is built with a modern tech stack:
```
4. **Apply database migrations (Prisma):**
- Ensure your Prisma schema (`prisma/schema.prisma`) is defined.
- Setup/update the database with these commands:
```bash

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);
filesToImport.map(async (file) => {
try {
const moduleImp = await import(file);
if (moduleImp.default) {
moduleImp.default(registry);
}
})
.catch((error) => {
} 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,47 +31,63 @@
"@fortawesome/react-fontawesome": "^0.2.2",
"@hookform/resolvers": "^5.0.1",
"@prisma/client": "^6.9.0",
"@radix-ui/react-dropdown-menu": "^2.1.14",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-hover-card": "^1.1.13",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-scroll-area": "^1.2.8",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.4",
"@radix-ui/react-separator": "^1.1.6",
"@radix-ui/react-slot": "^1.2.2",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.7",
"@tanstack/react-query": "^5.80.7",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.511.0",
"next": "15.4.0-canary.92",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.523.0",
"next": "15.3.4",
"next-auth": "^5.0.0-beta.25",
"next-swagger-doc": "^0.4.1",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react": "^19.1.0",
"react-big-calendar": "^1.18.0",
"react-datepicker": "^8.4.0",
"react-day-picker": "^9.7.0",
"react-dom": "^19.0.0",
"react-error-boundary": "^6.0.0",
"react-hook-form": "^7.56.4",
"sonner": "^2.0.5",
"swagger-ui-react": "^5.24.1",
"tailwind-merge": "^3.2.0",
"zod": "^3.25.60"
"zod": "^3.25.60",
"zod-validation-error": "^3.5.2"
},
"devDependencies": {
"@eslint/eslintrc": "3.3.1",
"@tailwindcss/postcss": "4.1.10",
"@types/node": "22.15.32",
"@tailwindcss/postcss": "4.1.11",
"@types/node": "22.15.33",
"@types/react": "19.1.8",
"@types/react-big-calendar": "1.16.2",
"@types/react-dom": "19.1.6",
"@types/swagger-ui-react": "5",
"@types/swagger-ui-react": "5.18.0",
"@types/webpack-env": "1.18.8",
"cypress": "14.5.0",
"dotenv-cli": "8.0.0",
"eslint": "9.29.0",
"eslint-config-next": "15.3.4",
"eslint-config-prettier": "10.1.5",
"orval": "7.10.0",
"postcss": "8.5.6",
"prettier": "3.5.3",
"prettier": "3.6.2",
"prisma": "6.10.1",
"tailwindcss": "4.1.10",
"tailwindcss": "4.1.11",
"ts-node": "10.9.2",
"tsconfig-paths": "4.2.0",
"tw-animate-css": "1.3.4",

View file

@ -0,0 +1,238 @@
'use client';
import React, { useState } from 'react';
import Logo from '@/components/misc/logo';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import {
useDeleteApiEventEventID,
useGetApiEventEventID,
} from '@/generated/api/event/event';
import { useGetApiUserMe } from '@/generated/api/user/user';
import { RedirectButton } from '@/components/buttons/redirect-button';
import { useSession } from 'next-auth/react';
import ParticipantListEntry from '@/components/custom-ui/participant-list-entry';
import { useParams, useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { ToastInner } from '@/components/misc/toast-inner';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
export default function ShowEvent() {
const session = useSession();
const router = useRouter();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const { eventID: eventID } = useParams<{ eventID: string }>();
// Fetch event data
const { data: eventData, isLoading, error } = useGetApiEventEventID(eventID);
const { data: userData, isLoading: userLoading } = useGetApiUserMe();
const deleteEvent = useDeleteApiEventEventID();
if (isLoading || userLoading) {
return (
<div className='flex justify-center items-center h-screen'>
Loading...
</div>
);
}
if (error || !eventData?.data?.event) {
return (
<div className='flex justify-center items-center h-screen'>
Error loading event.
</div>
);
}
const event = eventData.data.event;
const organiserName = userData?.data.user?.name || 'Unknown User';
// Format dates & times for display
const formatDate = (isoString?: string) => {
if (!isoString) return '-';
return new Date(isoString).toLocaleDateString();
};
const formatTime = (isoString?: string) => {
if (!isoString) return '-';
return new Date(isoString).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
};
return (
<div className='flex flex-col items-center justify-center h-screen'>
<Card className='w-[80%] max-w-screen p-0 gap-0 max-xl:w-[95%] max-h-[90vh] overflow-auto'>
<CardHeader className='p-0 m-0 gap-0' />
<CardContent>
<div className='flex flex-col gap-5 w-full'>
<div className='grid grid-row-start:auto gap-4 sm:gap-8'>
<div className='h-full mt-0 ml-2 mb-16 flex items-center justify-between max-sm:grid max-sm:grid-row-start:auto max-sm:mb-6 max-sm:mt-10 max-sm:ml-0'>
<div className='w-[100px] max-sm:w-full max-sm:flex max-sm:justify-center'>
<Logo colorType='monochrome' logoType='submark' width={50} />
</div>
<div className='items-center ml-auto mr-auto max-sm:mb-6 max-sm:w-full max-sm:flex max-sm:justify-center'>
<h1 className='text-center'>
{event.title || 'Untitled Event'}
</h1>
</div>
<div className='w-0 sm:w-[100px]'></div>
</div>
<div className='grid grid-cols-4 gap-4 h-full w-full max-lg:grid-cols-2 max-sm:grid-cols-1'>
<div>
<Label className='text-[var(--color-neutral-300)] mb-2'>
start Time
</Label>
<Label size='large'>
{event.start_time
? `${formatDate(event.start_time)} ${formatTime(event.start_time)}`
: '-'}
</Label>
</div>
<div>
<Label className='text-[var(--color-neutral-300)] mb-2'>
end Time
</Label>
<Label size='large'>
{event.end_time
? `${formatDate(event.end_time)} ${formatTime(event.end_time)}`
: '-'}
</Label>
</div>
<div className='w-54'>
<Label className='text-[var(--color-neutral-300)] mb-2'>
Location
</Label>
<Label size='large'>{event.location || '-'}</Label>
</div>
<div className='flex flex-col gap-4'>
<div className='flex flex-row gap-2'>
<Label className='w-[70px] text-[var(--color-neutral-300)]'>
created:
</Label>
<Label>
{event.created_at ? formatDate(event.created_at) : '-'}
</Label>
</div>
<div className='flex flex-row gap-2'>
<Label className='w-[70px] text-[var(--color-neutral-300)]'>
updated:
</Label>
<Label>
{event.updated_at ? formatDate(event.updated_at) : '-'}
</Label>
</div>
</div>
</div>
<div className='h-full w-full grid grid-cols-2 gap-4 max-sm:grid-cols-1'>
<div className='h-full w-full grid grid-flow-row gap-4 sm:gap-8'>
<div className='h-full w-full'>
<div className='flex flex-row gap-2'>
<Label className='text-[var(--color-neutral-300)]'>
Organiser:
</Label>
<Label size='large'>{organiserName}</Label>
</div>
</div>
<div className='h-full w-full'>
<Label className='text-[var(--color-neutral-300)] mb-2'>
Description
</Label>
<Label size='large'>{event.description || '-'}</Label>
</div>
</div>
<div className='h-full w-full mt-2'>
<Label className='text-[var(--color-neutral-300)] mb-2'>
Participants
</Label>{' '}
<div className='grid grid-cols-1 mt-3 sm:max-h-60 sm:grid-cols-2 sm:overflow-y-auto sm:mb-0'>
{event.participants?.map((user) => (
<ParticipantListEntry key={user.user.id} {...user} />
))}
</div>
</div>
</div>
<div className='flex flex-row gap-2 justify-end mt-4 mb-6'>
<div className='w-[20%] grid max-sm:w-full'>
{session.data?.user?.id === event.organizer.id ? (
<Dialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
>
<DialogTrigger asChild>
<Button variant='destructive' className='w-full'>
delete
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Event</DialogTitle>
<DialogDescription>
Are you sure you want to delete the event &ldquo;
{event.title}&rdquo;? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant='secondary'
onClick={() => setDeleteDialogOpen(false)}
>
Cancel
</Button>
<Button
variant='muted'
onClick={() => {
deleteEvent.mutate(
{ eventID: event.id },
{
onSuccess: () => {
router.push('/home');
toast.custom((t) => (
<ToastInner
toastId={t}
title='Event deleted'
description={event?.title}
variant='success'
/>
));
},
},
);
setDeleteDialogOpen(false);
}}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
) : null}
</div>
<div className='w-[20%] grid max-sm:w-full'>
{session.data?.user?.id === event.organizer.id ? (
<RedirectButton
redirectUrl={`/events/edit/${eventID}`}
buttonText='edit'
className='w-full'
/>
) : null}
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,24 @@
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import EventForm from '@/components/forms/event-form';
import { Suspense } from 'react';
export default async function Page({
params,
}: {
params: Promise<{ eventID: string }>;
}) {
const eventID = (await params).eventID;
return (
<div className='flex flex-col items-center justify-center h-screen'>
<Card className='w-[80%] max-w-screen p-0 gap-0 max-xl:w-[95%] max-h-[90vh] overflow-auto'>
<CardHeader className='p-0 m-0 gap-0' />
<CardContent>
<Suspense>
<EventForm type='edit' eventId={eventID} />
</Suspense>
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,21 @@
import { ThemePicker } from '@/components/misc/theme-picker';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import EventForm from '@/components/forms/event-form';
import { Suspense } from 'react';
export default function NewEvent() {
return (
<div className='flex flex-col items-center justify-center h-screen'>
<div className='absolute top-4 right-4'>{<ThemePicker />}</div>
<Card className='w-[80%] max-w-screen p-0 gap-0 max-xl:w-[95%] max-h-[90vh] overflow-auto'>
<CardHeader className='p-0 m-0 gap-0' />
<CardContent>
<Suspense>
<EventForm type='create' />
</Suspense>
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,54 @@
'use client';
import { RedirectButton } from '@/components/buttons/redirect-button';
import EventListEntry from '@/components/custom-ui/event-list-entry';
import { Label } from '@/components/ui/label';
import { useGetApiEvent } from '@/generated/api/event/event';
export default function Events() {
const { data: eventsData, isLoading, error } = useGetApiEvent();
if (isLoading) return <div className='text-center mt-10'>Loading...</div>;
if (error)
return (
<div className='text-center mt-10 text-red-500'>Error loading events</div>
);
const events = eventsData?.data?.events || [];
return (
<div className='relative h-screen flex flex-col items-center'>
{/* Heading */}
<h1 className='text-3xl font-bold mt-8 mb-4 text-center z-10'>
My Events
</h1>
{/* Scrollable event list */}
<div className='w-full flex justify-center overflow-hidden'>
<div className='grid gap-8 w-[90%] sm:w-[80%] lg:w-[60%] xl:w-[50%] p-6 overflow-y-auto'>
{events.length > 0 ? (
events.map((event) => (
<EventListEntry
key={event.id}
{...event}
created_at={new Date(event.created_at)}
updated_at={new Date(event.updated_at)}
/>
))
) : (
<div className='flex flex-1 flex-col items-center justify-center min-h-[300px]'>
<Label size='large' className='justify-center text-center'>
You don&#39;t have any events right now
</Label>
<RedirectButton
redirectUrl='/events/new'
buttonText='create Event'
className='mt-4'
/>
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,17 @@
'use client';
import Calendar from '@/components/calendar';
import { useGetApiUserMe } from '@/generated/api/user/user';
export default function Home() {
const { data } = useGetApiUserMe();
return (
<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,6 +192,7 @@ export const GET = auth(async function GET(req, { params }) {
}
for (const slot of requestedUser.blockedSlots) {
if (requestUserId === requestedUserId) {
calendar.push({
start_time: slot.start_time,
end_time: slot.end_time,
@ -200,13 +203,24 @@ export const GET = auth(async function GET(req, { params }) {
rrule: slot.rrule,
created_at: slot.created_at,
updated_at: slot.updated_at,
type:
requestUserId === requestedUserId ? 'blocked_owned' : 'blocked_private',
type: 'blocked_owned',
});
} else {
calendar.push({
start_time: slot.start_time,
end_time: slot.end_time,
id: slot.id,
type: 'blocked_private',
});
}
}
return returnZodTypeCheckedResponse(UserCalendarResponseSchema, {
success: true,
calendar,
calendar: calendar.filter(
(event, index, self) =>
self.findIndex((e) => e.id === event.id && e.type === event.type) ===
index,
),
});
});

View file

@ -13,12 +13,16 @@ 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({
export const OwnedBlockedSlotSchema = zod
.object({
start_time: eventStartTimeSchema,
end_time: eventEndTimeSchema,
id: zod.string(),
reason: zod.string().nullish(),
is_recurring: zod.boolean().default(false),
@ -27,7 +31,8 @@ export const OwnedBlockedSlotSchema = BlockedSlotSchema.extend({
created_at: zod.date().nullish(),
updated_at: zod.date().nullish(),
type: zod.literal('blocked_owned'),
}).openapi('OwnedBlockedSlotSchema', {
})
.openapi('OwnedBlockedSlotSchema', {
description: 'Blocked slot owned by the user',
});

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

@ -9,6 +9,7 @@
--font-heading: 'Comfortaa', sans-serif;
--font-label: 'Varela Round', sans-serif;
--font-button: 'Varela Round', sans-serif;
--font-sans: var(--font-label);
--transparent: transparent;
@ -28,11 +29,12 @@
--background: var(--neutral-800);
--background-reversed: var(--neutral-000);
--base: var(--neutral-800);
--basecl: var(--neutral-800);
--text: var(--neutral-000);
--text-alt: var(--neutral-900);
--text-input: var(--text);
--text-muted-input: var(--neutral-450);
--text-muted: var(--neutral-300);
--muted-input: var(--neutral-600);
--background-disabled: var(--neutral-500);
--text-disabled: var(--neutral-700);
@ -48,13 +50,27 @@
--active-secondary: oklch(0.4254 0.133 272.15);
--disabled-secondary: oklch(0.4937 0.1697 271.26 / 0.5);
--destructive: oklch(60.699% 0.20755 25.945);
--hover-destructive: oklch(60.699% 0.20755 25.945 / 0.8);
--active-destructive: oklch(50.329% 0.17084 25.842);
--disabled-destructive: oklch(60.699% 0.20755 25.945 / 0.4);
--muted: var(--color-neutral-700);
--hover-muted: var(--color-neutral-600);
--active-muted: var(--color-neutral-400);
--disabled-muted: var(--color-neutral-400);
--toaster-default-bg: var(--color-neutral-150);
--toaster-success-bg: oklch(54.147% 0.09184 144.208);
--toaster-error-bg: oklch(52.841% 0.10236 27.274);
--toaster-info-bg: oklch(44.298% 0.05515 259.369);
--toaster-warning-bg: oklch(61.891% 0.07539 102.943);
--toaster-notification-bg: var(--color-neutral-150);
--card: var(--neutral-800);
--sidebar-width-icon: 32px;
/* ------------------- */
--foreground: oklch(0.13 0.028 261.692);
@ -77,8 +93,6 @@
--accent-foreground: oklch(0.21 0.034 264.665);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.928 0.006 264.531);
--input: oklch(0.928 0.006 264.531);
@ -95,23 +109,79 @@
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0.002 247.839);
--sidebar: var(--background);
--sidebar-foreground: oklch(0.13 0.028 261.692);
--sidebar-foreground: var(--text);
--sidebar-primary: oklch(0.21 0.034 264.665);
--sidebar-primary-foreground: oklch(0.985 0.002 247.839);
--sidebar-primary-foreground: var(--text);
--sidebar-accent: oklch(0.967 0.003 264.542);
--sidebar-accent-foreground: oklch(0.21 0.034 264.665);
--sidebar-accent-foreground: var(--text);
--sidebar-border: oklch(0.928 0.006 264.531);
--sidebar-ring: oklch(0.707 0.022 261.325);
}
h1 {
font-family: var(--font-heading);
font-size: 40px;
font-style: normal;
font-weight: 700;
line-height: normal;
}
h2 {
font-family: var(--font-heading);
font-size: 36px;
font-style: normal;
font-weight: 600;
line-height: normal;
}
h3 {
font-family: var(--font-heading);
font-size: 32px;
font-style: normal;
font-weight: 600;
line-height: normal;
}
h4 {
font-family: var(--font-heading);
font-size: 28px;
font-style: normal;
font-weight: 600;
line-height: normal;
}
h5 {
font-family: var(--font-heading);
font-size: 26px;
font-style: normal;
font-weight: 600;
line-height: normal;
}
h6 {
font-family: var(--font-heading);
font-size: 20px;
font-style: normal;
font-weight: 600;
line-height: normal;
}
p {
font-family: var(--font-label);
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
}
@font-face {
font-family: 'Comfortaa';
font-style: normal;
@ -150,11 +220,12 @@
--color-background: var(--neutral-750);
--color-background-reversed: var(--background-reversed);
--color-base: var(--neutral-800);
--color-basecl: var(--neutral-800);
--color-text: var(--text);
--color-text-alt: var(--text-alt);
--color-text-input: var(--text-input);
--color-text-muted-input: var(--text-muted-input);
--color-text-muted: var(--text-muted);
--color-muted-input: var(--muted-input);
--color-background-disabled: var(--neutral-500);
@ -171,11 +242,23 @@
--color-active-secondary: var(--active-secondary);
--color-disabled-secondary: var(--disabled-secondary);
--color-destructive: var(--destructive);
--color-hover-destructive: var(--hover-destructive);
--color-active-destructive: var(--active-destructive);
--color-disabled-destructive: var(--disabled-destructive);
--color-muted: var(--muted);
--color-hover-muted: var(--hover-muted);
--color-active-muted: var(--active-muted);
--color-disabled-muted: var(--disabled-muted);
--color-toaster-default-bg: var(--toaster-default-bg);
--color-toaster-success-bg: var(--toaster-success-bg);
--color-toaster-error-bg: var(--toaster-error-bg);
--color-toaster-info-bg: var(--toaster-info-bg);
--color-toaster-warning-bg: var(--toaster-warning-bg);
--color-toaster-notification-bg: var(--toaster-notification-bg);
/* Custom values */
--radius-sm: calc(var(--radius) - 4px);
@ -216,8 +299,6 @@
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
@ -273,11 +354,12 @@
--background: var(--neutral-750);
--background-reversed: var(--neutral-000);
--base: var(--neutral-750);
--basecl: var(--neutral-750);
--text: var(--neutral-000);
--text-alt: var(--neutral-900);
--text-input: var(--text);
--text-muted-input: var(--neutral-450);
--text-muted: var(--neutral-300);
--muted-input: var(--neutral-500);
--background-disabled: var(--neutral-500);
--text-disabled: var(--neutral-700);
@ -292,11 +374,23 @@
--active-secondary: oklch(0.4471 0.15 271.61);
--disabled-secondary: oklch(0.6065 0.213 271.11 / 0.4);
--destructive: oklch(0.58 0.2149 27.13);
--hover-destructive: oklch(0.58 0.2149 27.13 / 0.8);
--active-destructive: oklch(45.872% 0.16648 26.855);
--disabled-destructive: oklch(0.58 0.2149 27.13 / 0.4);
--muted: var(--color-neutral-650);
--hover-muted: var(--color-neutral-500);
--active-muted: var(--color-neutral-400);
--disabled-muted: var(--color-neutral-400);
--toaster-default-bg: var(--color-neutral-150);
--toaster-success-bg: var(--color-green-200);
--toaster-error-bg: var(--color-red-200);
--toaster-info-bg: var(--color-blue-200);
--toaster-warning-bg: var(--color-yellow-200);
--toaster-notification-bg: var(--color-neutral-150);
--card: var(--neutral-750);
/* ------------------- */
@ -321,8 +415,6 @@
--accent-foreground: oklch(0.985 0.002 247.839);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
@ -339,17 +431,17 @@
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.034 264.665);
--sidebar: var(--background);
--sidebar-foreground: oklch(0.985 0.002 247.839);
--sidebar-foreground: var(--text);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0.002 247.839);
--sidebar-primary-foreground: var(--text);
--sidebar-accent: oklch(0.278 0.033 256.848);
--sidebar-accent-foreground: oklch(0.985 0.002 247.839);
--sidebar-accent-foreground: var(--text);
--sidebar-border: oklch(1 0 0 / 10%);

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,9 @@ import { ThemeProvider } from '@/components/wrappers/theme-provider';
import type { Metadata } from 'next';
import './globals.css';
import { QueryProvider } from '@/components/query-provider';
import { QueryProvider } from '@/components/wrappers/query-provider';
import { Toaster } from '@/components/ui/sonner';
import { SessionProvider } from 'next-auth/react';
export const metadata: Metadata = {
title: 'MeetUp',
@ -50,6 +52,7 @@ export default function RootLayout({
<link rel='manifest' href='/site.webmanifest' />
</head>
<body>
<SessionProvider>
<ThemeProvider
attribute='class'
defaultTheme='system'
@ -58,6 +61,8 @@ export default function RootLayout({
>
<QueryProvider>{children}</QueryProvider>
</ThemeProvider>
</SessionProvider>
<Toaster />
</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,482 +1,5 @@
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ScrollableSettingsWrapper } from '@/components/wrappers/settings-scroll';
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import SettingsPage from '@/components/misc/settings-page';
export default function SettingsPage() {
return (
<div className='fixed inset-0 flex items-center justify-center p-4 bg-background/50 backdrop-blur-sm'>
<div className='rounded-lg border bg-card text-card-foreground shadow-xl max-w-[700px] w-full h-auto max-h-[calc(100vh-2rem)] flex flex-col'>
<Tabs
defaultValue='general'
className='w-full flex flex-col flex-grow min-h-0'
>
<TabsList className='grid w-full grid-cols-3 sm:grid-cols-5'>
<TabsTrigger value='general'>Account</TabsTrigger>
<TabsTrigger value='notifications'>Notifications</TabsTrigger>
<TabsTrigger value='calendarAvailability'>Calendar</TabsTrigger>
<TabsTrigger value='sharingPrivacy'>Privacy</TabsTrigger>
<TabsTrigger value='appearance'>Appearance</TabsTrigger>
</TabsList>
<TabsContent value='general' className='flex-grow overflow-hidden'>
<Card className='h-full flex flex-col border-0 shadow-none rounded-none'>
<ScrollableSettingsWrapper>
<CardHeader>
<CardTitle>Account Settings</CardTitle>
<CardDescription>
Manage your account details and preferences.
</CardDescription>
</CardHeader>
<CardContent className='space-y-6'>
<div className='space-y-2'>
<Label htmlFor='displayName'>Display Name</Label>
<Input id='displayName' placeholder='Your Name' />
</div>
<div className='space-y-2'>
<Label htmlFor='email'>Email Address</Label>
<Input
id='email'
type='email'
placeholder='your.email@example.com'
readOnly
value='user-email@example.com'
/>
<p className='text-sm text-muted-foreground'>
Email is managed by your SSO provider.
</p>
</div>
<div className='space-y-2'>
<Label htmlFor='profilePicture'>Profile Picture</Label>
<Input id='profilePicture' type='file' />
<p className='text-sm text-muted-foreground'>
Upload a new profile picture.
</p>
</div>
<div className='space-y-2'>
<Label htmlFor='timezone'>Timezone</Label>
<Input id='displayName' placeholder='Europe/Berlin' />
</div>
<div className='space-y-2'>
<Label htmlFor='language'>Language</Label>
<Select>
<SelectTrigger id='language'>
<SelectValue placeholder='Select language' />
</SelectTrigger>
<SelectContent>
<SelectItem value='en'>English</SelectItem>
<SelectItem value='de'>German</SelectItem>
</SelectContent>
</Select>
</div>
<div className='pt-4'>
<Button variant='secondary'>Delete Account</Button>
<p className='text-sm text-muted-foreground pt-1'>
Permanently delete your account and all associated data.
</p>
</div>
</CardContent>
</ScrollableSettingsWrapper>
<CardFooter className='mt-auto border-t pt-4 flex justify-between'>
<Button variant='secondary'>Exit</Button>
<Button>Save Changes</Button>
</CardFooter>
</Card>
</TabsContent>
<TabsContent
value='notifications'
className='flex-grow overflow-hidden'
>
<Card className='h-full flex flex-col border-0 shadow-none rounded-none'>
<ScrollableSettingsWrapper>
<CardHeader>
<CardTitle>Notification Preferences</CardTitle>
<CardDescription>
Choose how you want to be notified.
</CardDescription>
</CardHeader>
<CardContent className='space-y-6'>
<div className='flex items-center justify-between space-x-2 p-3 rounded-md border'>
<Label
htmlFor='masterEmailNotifications'
className='font-normal'
>
Enable All Email Notifications
</Label>
<Switch id='masterEmailNotifications' />
</div>
<div className='space-y-4 pl-2 border-l-2 ml-2'>
<div className='flex items-center justify-between space-x-2'>
<Label
htmlFor='newMeetingBookings'
className='font-normal'
>
New Meeting Bookings
</Label>
<Switch id='newMeetingBookings' />
</div>
<div className='flex items-center justify-between space-x-2'>
<Label
htmlFor='meetingConfirmations'
className='font-normal'
>
Meeting Confirmations/Cancellations
</Label>
<Switch id='meetingConfirmations' />
</div>
<div className='flex items-center justify-between space-x-2'>
<Label
htmlFor='enableMeetingReminders'
className='font-normal'
>
Meeting Reminders
</Label>
<Switch id='enableMeetingReminders' />
</div>
<div className='space-y-2 pl-6'>
<Label htmlFor='remindBefore'>Remind me before</Label>
<Select>
<SelectTrigger id='remindBefore'>
<SelectValue placeholder='Select reminder time' />
</SelectTrigger>
<SelectContent>
<SelectItem value='15m'>15 minutes</SelectItem>
<SelectItem value='30m'>30 minutes</SelectItem>
<SelectItem value='1h'>1 hour</SelectItem>
<SelectItem value='1d'>1 day</SelectItem>
</SelectContent>
</Select>
</div>
<div className='flex items-center justify-between space-x-2'>
<Label htmlFor='friendRequests' className='font-normal'>
Friend Requests
</Label>
<Switch id='friendRequests' />
</div>
<div className='flex items-center justify-between space-x-2'>
<Label htmlFor='groupUpdates' className='font-normal'>
Group Invitations/Updates
</Label>
<Switch id='groupUpdates' />
</div>
</div>
</CardContent>
</ScrollableSettingsWrapper>
<CardFooter className='mt-auto border-t pt-4 flex justify-between'>
<Button variant='secondary'>Exit</Button>
<Button>Save Changes</Button>
</CardFooter>
</Card>
</TabsContent>
<TabsContent
value='calendarAvailability'
className='flex-grow overflow-hidden'
>
<Card className='h-full flex flex-col border-0 shadow-none rounded-none'>
<ScrollableSettingsWrapper>
<CardHeader>
<CardTitle>Calendar & Availability</CardTitle>
<CardDescription>
Manage your calendar display, default availability, and iCal
integrations.
</CardDescription>
</CardHeader>
<CardContent className='space-y-6'>
<fieldset className='space-y-4 p-4 border rounded-md'>
<legend className='text-sm font-medium px-1'>
Display
</legend>
<div className='space-y-2'>
<Label htmlFor='defaultCalendarView'>
Default Calendar View
</Label>
<Select>
<SelectTrigger id='defaultCalendarView'>
<SelectValue placeholder='Select view' />
</SelectTrigger>
<SelectContent>
<SelectItem value='day'>Day</SelectItem>
<SelectItem value='week'>Week</SelectItem>
<SelectItem value='month'>Month</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='weekStartsOn'>Week Starts On</Label>
<Select>
<SelectTrigger id='weekStartsOn'>
<SelectValue placeholder='Select day' />
</SelectTrigger>
<SelectContent>
<SelectItem value='sunday'>Sunday</SelectItem>
<SelectItem value='monday'>Monday</SelectItem>
</SelectContent>
</Select>
</div>
<div className='flex items-center justify-between space-x-2'>
<Label htmlFor='showWeekends' className='font-normal'>
Show Weekends
</Label>
<Switch id='showWeekends' defaultChecked />
</div>
</fieldset>
<fieldset className='space-y-4 p-4 border rounded-md'>
<legend className='text-sm font-medium px-1'>
Availability
</legend>
<div className='space-y-2'>
<Label>Working Hours</Label>
<p className='text-sm text-muted-foreground'>
Define your typical available hours (e.g.,
Monday-Friday, 9 AM - 5 PM).
</p>
<Button variant='outline_muted' size='sm'>
Set Working Hours
</Button>
</div>
<div className='space-y-2'>
<Label htmlFor='minNoticeBooking'>
Minimum Notice for Bookings
</Label>
<p className='text-sm text-muted-foreground'>
Min time before a booking can be made.
</p>
<div className='space-y-2'>
<Input
id='bookingWindow'
type='text'
placeholder='e.g., 1h'
/>
</div>
</div>
<div className='space-y-2'>
<Label htmlFor='bookingWindow'>
Booking Window (days in advance)
</Label>
<p className='text-sm text-muted-foreground'>
Max time in advance a booking can be made.
</p>
<Input
id='bookingWindow'
type='number'
placeholder='e.g., 30d'
/>
</div>
</fieldset>
<fieldset className='space-y-4 p-4 border rounded-md'>
<legend className='text-sm font-medium px-1'>
iCalendar Integration
</legend>
<div className='space-y-2'>
<Label htmlFor='icalImport'>Import iCal Feed URL</Label>
<Input
id='icalImport'
type='url'
placeholder='https://calendar.example.com/feed.ics'
/>
<Button size='sm' className='mt-1'>
Add Feed
</Button>
</div>
<div className='space-y-2'>
<Label>Export Your Calendar</Label>
<Button variant='outline_muted' size='sm'>
Get iCal Export URL
</Button>
<Button
variant='outline_muted'
size='sm'
className='ml-2'
>
Download .ics File
</Button>
</div>
</fieldset>
</CardContent>
</ScrollableSettingsWrapper>
<CardFooter className='mt-auto border-t pt-4 flex justify-between'>
<Button variant='secondary'>Exit</Button>
<Button>Save Changes</Button>
</CardFooter>
</Card>
</TabsContent>
<TabsContent
value='sharingPrivacy'
className='flex-grow overflow-hidden'
>
<Card className='h-full flex flex-col border-0 shadow-none rounded-none'>
<ScrollableSettingsWrapper>
<CardHeader>
<CardTitle>Sharing & Privacy</CardTitle>
<CardDescription>
Control who can see your calendar and book time with you.
</CardDescription>
</CardHeader>
<CardContent className='space-y-6'>
<div className='space-y-2'>
<Label htmlFor='defaultVisibility'>
Default Calendar Visibility
</Label>
<Select>
<SelectTrigger id='defaultVisibility'>
<SelectValue placeholder='Select visibility' />
</SelectTrigger>
<SelectContent>
<SelectItem value='private'>
Private (Only You)
</SelectItem>
<SelectItem value='freebusy'>
Free/Busy for Friends
</SelectItem>
<SelectItem value='fulldetails'>
Full Details for Friends
</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='whoCanSeeFull'>
Who Can See Your Full Calendar Details?
</Label>
<p className='text-sm text-muted-foreground'>
(Override for Default Visibility)
<br />
<span className='text-sm text-muted-foreground'>
This setting will override the default visibility for
your calendar. You can set specific friends or groups to
see your full calendar details.
</span>
</p>
<Select>
<SelectTrigger id='whoCanSeeFull'>
<SelectValue placeholder='Select audience' />
</SelectTrigger>
<SelectContent>
<SelectItem value='me'>Only Me</SelectItem>
<SelectItem value='friends'>My Friends</SelectItem>
<SelectItem value='specific'>
Specific Friends/Groups (manage separately)
</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='whoCanBook'>
Who Can Book Time With You?
</Label>
<Select>
<SelectTrigger id='whoCanBook'>
<SelectValue placeholder='Select audience' />
</SelectTrigger>
<SelectContent>
<SelectItem value='none'>No One</SelectItem>
<SelectItem value='friends'>My Friends</SelectItem>
<SelectItem value='specific'>
Specific Friends/Groups (manage separately)
</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label>Blocked Users</Label>
<Button variant='outline_muted'>
Manage Blocked Users
</Button>
<p className='text-sm text-muted-foreground'>
Prevent specific users from seeing your calendar or
booking time.
</p>
</div>
</CardContent>
</ScrollableSettingsWrapper>
<CardFooter className='mt-auto border-t pt-4 flex justify-between'>
<Button variant='secondary'>Exit</Button>
<Button>Save Changes</Button>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value='appearance' className='flex-grow overflow-hidden'>
<Card className='h-full flex flex-col border-0 shadow-none rounded-none'>
<ScrollableSettingsWrapper>
<CardHeader>
<CardTitle>Appearance</CardTitle>
<CardDescription>
Customize the look and feel of the application.
</CardDescription>
</CardHeader>
<CardContent className='space-y-6'>
<div className='space-y-2'>
<Label htmlFor='theme'>Theme</Label>
<Select>
<SelectTrigger id='theme'>
<SelectValue placeholder='Select theme' />
</SelectTrigger>
<SelectContent>
<SelectItem value='light'>Light</SelectItem>
<SelectItem value='dark'>Dark</SelectItem>
<SelectItem value='system'>System Default</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='dateFormat'>Date Format</Label>
<Select>
<SelectTrigger id='dateFormat'>
<SelectValue placeholder='Select date format' />
</SelectTrigger>
<SelectContent>
<SelectItem value='ddmmyyyy'>DD/MM/YYYY</SelectItem>
<SelectItem value='mmddyyyy'>MM/DD/YYYY</SelectItem>
<SelectItem value='yyyymmdd'>YYYY-MM-DD</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='timeFormat'>Time Format</Label>
<Select>
<SelectTrigger id='timeFormat'>
<SelectValue placeholder='Select time format' />
</SelectTrigger>
<SelectContent>
<SelectItem value='24h'>24-hour</SelectItem>
<SelectItem value='12h'>12-hour</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</ScrollableSettingsWrapper>
<CardFooter className='mt-auto border-t pt-4 flex justify-between'>
<Button variant='secondary'>Exit</Button>
<Button>Save Changes</Button>
</CardFooter>
</Card>
</TabsContent>
</Tabs>
</div>
</div>
);
export default function Page() {
return <SettingsPage />;
}

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,5 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 11C0 4.92487 4.92487 0 11 0H29C35.0751 0 40 4.92487 40 11V29C40 35.0751 35.0751 40 29 40H11C4.92487 40 0 35.0751 0 29V11Z" fill="#5770FF"/>
<path d="M31.6663 35V31.6667C31.6663 29.8986 30.964 28.2029 29.7137 26.9526C28.4635 25.7024 26.7678 25 24.9997 25H14.9997C13.2316 25 11.5359 25.7024 10.2856 26.9526C9.03539 28.2029 8.33301 29.8986 8.33301 31.6667V35" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19.9997 18.3333C23.6816 18.3333 26.6663 15.3486 26.6663 11.6667C26.6663 7.98477 23.6816 5 19.9997 5C16.3178 5 13.333 7.98477 13.333 11.6667C13.333 15.3486 16.3178 18.3333 19.9997 18.3333Z" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 833 B

View file

@ -0,0 +1,5 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 11C0 4.92487 4.92487 0 11 0H29C35.0751 0 40 4.92487 40 11V29C40 35.0751 35.0751 40 29 40H11C4.92487 40 0 35.0751 0 29V11Z" fill="#4154C0"/>
<path d="M31.6663 35V31.6667C31.6663 29.8986 30.964 28.2029 29.7137 26.9526C28.4635 25.7024 26.7678 25 24.9997 25H14.9997C13.2316 25 11.5359 25.7024 10.2856 26.9526C9.03539 28.2029 8.33301 29.8986 8.33301 31.6667V35" stroke="black" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19.9997 18.3333C23.6816 18.3333 26.6663 15.3486 26.6663 11.6667C26.6663 7.98477 23.6816 5 19.9997 5C16.3178 5 13.333 7.98477 13.333 11.6667C13.333 15.3486 16.3178 18.3333 19.9997 18.3333Z" stroke="black" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 833 B

View file

@ -0,0 +1,2 @@
export { default as user_default_dark } from '@/assets/usericon/default/default-user-icon_dark.svg';
export { default as user_default_light } from '@/assets/usericon/default/default-user-icon_light.svg';

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

@ -1,16 +1,18 @@
import { Button } from '../ui/button';
import { Button } from '@/components/ui/button';
import Link from 'next/link';
export function RedirectButton({
redirectUrl,
buttonText,
className,
}: {
redirectUrl: string;
buttonText: string;
className?: string;
}) {
return (
<Link href={redirectUrl}>
<Button>{buttonText}</Button>
<Button className={className}>{buttonText}</Button>
</Link>
);
}

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>

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

@ -0,0 +1,256 @@
'use client';
import { Calendar as RBCalendar, momentLocalizer } from 'react-big-calendar';
import withDragAndDrop from 'react-big-calendar/lib/addons/dragAndDrop';
import moment from 'moment';
import '@/components/react-big-calendar.css';
import 'react-big-calendar/lib/addons/dragAndDrop/styles.css';
import CustomToolbar from '@/components/custom-toolbar';
import React from 'react';
import { useGetApiUserUserCalendar } from '@/generated/api/user/user';
import { useRouter } from 'next/navigation';
import { usePatchApiEventEventID } from '@/generated/api/event/event';
import { useSession } from 'next-auth/react';
import { UserCalendarSchemaItem } from '@/generated/api/meetup.schemas';
import { QueryErrorResetBoundary } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';
import { Button } from '@/components/ui/button';
import { fromZodIssue } from 'zod-validation-error/v4';
import type { $ZodIssue } from 'zod/v4/core';
moment.updateLocale('en', {
week: {
dow: 1,
doy: 4,
},
});
const DaDRBCalendar = withDragAndDrop<
{
id: string;
start: Date;
end: Date;
type: UserCalendarSchemaItem['type'];
},
{
id: string;
title: string;
type: UserCalendarSchemaItem['type'];
}
>(RBCalendar);
const localizer = momentLocalizer(moment);
export default function Calendar({
userId,
height,
}: {
userId?: string;
height: string;
}) {
return (
<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: '/events',
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='/events/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,68 @@
import { Card } from '@/components/ui/card';
import Logo from '@/components/misc/logo';
import { Label } from '@/components/ui/label';
import Link from 'next/link';
import zod from 'zod/v4';
import { EventSchema } from '@/app/api/event/validation';
type EventListEntryProps = zod.output<typeof EventSchema>;
export default function EventListEntry({
title,
id,
start_time,
end_time,
location,
}: EventListEntryProps) {
const formatDate = (isoString?: string) => {
if (!isoString) return '-';
return new Date(isoString).toLocaleDateString();
};
const formatTime = (isoString?: string) => {
if (!isoString) return '-';
return new Date(isoString).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
};
return (
<Link href={`/events/${id}`} className='block'>
<Card className='w-full'>
<div className='grid grid-cols-1 gap-2 mx-auto md:mx-4 md:grid-cols-[80px_1fr_250px]'>
<div className='w-full items-center justify-center grid'>
<Logo colorType='monochrome' logoType='submark' width={50} />
</div>
<div className='w-full items-center justify-center grid my-3 md:my-0'>
<h2 className='text-center'>{title}</h2>
</div>
<div className='grid gap-4'>
<div className='grid grid-cols-[80px_auto] gap-2'>
<Label className='text-[var(--color-neutral-300)] justify-end'>
start
</Label>
<Label>
{formatDate(start_time)} {formatTime(start_time)}
</Label>
</div>
<div className='grid grid-cols-[80px_auto] gap-2'>
<Label className='text-[var(--color-neutral-300)] justify-end'>
end
</Label>
<Label>
{formatDate(end_time)} {formatTime(end_time)}
</Label>
</div>
{location && (
<div className='grid grid-cols-[80px_auto] gap-2'>
<Label className='text-[var(--color-neutral-300)] justify-end'>
location
</Label>
<Label>{location}</Label>
</div>
)}
</div>
</div>
</Card>
</Link>
);
}

View file

@ -1,5 +1,9 @@
import { Input } from '@/components/ui/input';
import { Input, Textarea } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import React from 'react';
import { Button } from '../ui/button';
import { Eye, EyeOff } from 'lucide-react';
import { cn } from '@/lib/utils';
export default function LabeledInput({
type,
@ -7,24 +11,43 @@ export default function LabeledInput({
placeholder,
value,
name,
variantSize = 'default',
autocomplete,
error,
...rest
}: {
type: 'text' | 'email' | 'password';
type: 'text' | 'email' | 'password' | 'file';
label: string;
placeholder?: string;
value?: string;
name?: string;
variantSize?: 'default' | 'big' | 'textarea';
autocomplete?: string;
error?: string;
} & React.InputHTMLAttributes<HTMLInputElement>) {
const [passwordVisible, setPasswordVisible] = React.useState(false);
return (
<div className='grid grid-cols-1 gap-1'>
<Label htmlFor={name}>{label}</Label>
{variantSize === 'textarea' ? (
<Textarea
placeholder={placeholder}
defaultValue={value}
id={name}
name={name}
rows={3}
/>
) : (
<span className='relative'>
<Input
type={type}
className={cn(
type === 'password' ? 'pr-[50px]' : '',
variantSize === 'big'
? 'h-12 file:h-10 text-lg gplaceholder:text-lg sm:text-2xl sm:placeholder:text-2xl'
: '',
)}
type={passwordVisible ? 'text' : type}
placeholder={placeholder}
defaultValue={value}
id={name}
@ -32,6 +55,20 @@ export default function LabeledInput({
autoComplete={autocomplete}
{...rest}
/>
{type === 'password' && (
<Button
className='absolute right-0 top-0 w-[36px] h-[36px]'
type='button'
variant={'outline_muted'}
onClick={() => setPasswordVisible((visible) => !visible)}
>
{passwordVisible ? <Eye /> : <EyeOff />}
</Button>
)}
</span>
)}
{error && <p className='text-red-500 text-sm mt-1'>{error}</p>}
</div>
);

View file

@ -0,0 +1,26 @@
import React from 'react';
import Image from 'next/image';
import { user_default_dark } from '@/assets/usericon/default/defaultusericon-export';
import { user_default_light } from '@/assets/usericon/default/defaultusericon-export';
import { useTheme } from 'next-themes';
import zod from 'zod/v4';
import { ParticipantSchema } from '@/app/api/event/[eventID]/participant/validation';
type ParticipantListEntryProps = zod.output<typeof ParticipantSchema>;
export default function ParticipantListEntry({
user,
}: ParticipantListEntryProps) {
const { resolvedTheme } = useTheme();
const defaultImage =
resolvedTheme === 'dark' ? user_default_dark : user_default_light;
const finalImageSrc = user.image ?? defaultImage;
return (
<div className='flex items-center gap-2 py-1 ml-5'>
<Image src={finalImageSrc} alt='Avatar' width={30} height={30} />
<span>{user.name}</span>
</div>
);
}

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

@ -0,0 +1,343 @@
'use client';
import React from 'react';
import LabeledInput from '@/components/custom-ui/labeled-input';
import { Button } from '@/components/ui/button';
import Logo from '@/components/misc/logo';
import TimePicker from '@/components/time-picker';
import { Label } from '@/components/ui/label';
import { useGetApiUserMe } from '@/generated/api/user/user';
import {
usePostApiEvent,
useGetApiEventEventID,
usePatchApiEventEventID,
} from '@/generated/api/event/event';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { ToastInner } from '@/components/misc/toast-inner';
import { UserSearchInput } from '@/components/misc/user-search';
import ParticipantListEntry from '../custom-ui/participant-list-entry';
import { useSearchParams } from 'next/navigation';
import zod from 'zod/v4';
import { PublicUserSchema } from '@/app/api/user/validation';
type User = zod.output<typeof PublicUserSchema>;
interface EventFormProps {
type: 'create' | 'edit';
eventId?: string;
}
const EventForm: React.FC<EventFormProps> = (props) => {
// Runtime validation
if (props.type === 'edit' && !props.eventId) {
throw new Error(
'Error [event-form]: eventId must be provided when type is "edit".',
);
}
const searchParams = useSearchParams();
const startFromUrl = searchParams.get('start');
const endFromUrl = searchParams.get('end');
const { mutate: createEvent, status, isSuccess, error } = usePostApiEvent();
const { data, isLoading, error: fetchError } = useGetApiUserMe();
const { data: eventData } = useGetApiEventEventID(props.eventId!, {
query: { enabled: props.type === 'edit' },
});
const patchEvent = usePatchApiEventEventID();
const router = useRouter();
// Extract event fields for form defaults
const event = eventData?.data?.event;
// State for date and time fields
const [startDate, setStartDate] = React.useState<Date | undefined>(undefined);
const [startTime, setStartTime] = React.useState('');
const [endDate, setEndDate] = React.useState<Date | undefined>(undefined);
const [endTime, setEndTime] = React.useState('');
// State for participants
const [selectedParticipants, setSelectedParticipants] = React.useState<
User[]
>([]);
// State for form fields
const [title, setTitle] = React.useState('');
const [location, setLocation] = React.useState('');
const [description, setDescription] = React.useState('');
// Update state when event data loads
React.useEffect(() => {
if (props.type === 'edit' && event) {
setTitle(event.title || '');
// Parse start_time and end_time
if (event.start_time) {
const start = new Date(event.start_time);
setStartDate(start);
setStartTime(start.toTimeString().slice(0, 5)); // "HH:mm"
}
if (event.end_time) {
const end = new Date(event.end_time);
setEndDate(end);
setEndTime(end.toTimeString().slice(0, 5)); // "HH:mm"
}
setLocation(event.location || '');
setDescription(event.description || '');
setSelectedParticipants(event.participants?.map((u) => u.user) || []);
} else if (props.type === 'create' && startFromUrl && endFromUrl) {
// If creating a new event with URL params, set title and dates
setTitle('');
const start = new Date(startFromUrl);
setStartDate(start);
setStartTime(start.toTimeString().slice(0, 5)); // "HH:mm"
const end = new Date(endFromUrl);
setEndDate(end);
setEndTime(end.toTimeString().slice(0, 5)); // "HH:mm"
}
}, [event, props.type, startFromUrl, endFromUrl]);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
function combine(date?: Date, time?: string) {
if (!date || !time) return undefined;
const [hours, minutes] = time.split(':');
const d = new Date(date);
d.setHours(Number(hours), Number(minutes), 0, 0);
return d;
}
const start = combine(startDate, startTime);
const end = combine(endDate, endTime);
//validate form data
if (!formData.get('eventName')) {
alert('Event name is required.');
return;
}
if (!start || !end) {
alert('Please provide both start and end date/time.');
return;
} else if (start >= end) {
alert('End time must be after start time.');
return;
}
const data = {
title: formData.get('eventName') as string,
description: formData.get('eventDescription') as string,
start_time: start.toISOString(),
end_time: end.toISOString(),
location: formData.get('eventLocation') as string,
created_at: formData.get('createdAt') as string,
updated_at: formData.get('updatedAt') as string,
organiser: formData.get('organiser') as string,
participants: selectedParticipants.map((u) => u.id),
};
if (props.type === 'edit' && props.eventId) {
await patchEvent.mutateAsync({
eventID: props.eventId,
data: {
title: data.title,
description: data.description,
start_time: data.start_time,
end_time: data.end_time,
location: data.location,
participants: data.participants,
},
});
console.log('Updating event');
} else {
console.log('Creating event');
createEvent({ data });
}
toast.custom((t) => (
<ToastInner
toastId={t}
title='Event saved'
description={event?.title}
onAction={() => router.push(`/events/${event?.id}`)}
variant='success'
buttonText='show'
/>
));
router.back();
}
// Calculate values for organiser, created, and updated
const organiserValue = isLoading
? 'Loading...'
: data?.data.user?.name || 'Unknown User';
// Use DB values for created_at/updated_at in edit mode
const createdAtValue =
props.type === 'edit' && event?.created_at
? event.created_at
: new Date().toISOString();
const updatedAtValue =
props.type === 'edit' && event?.updated_at
? event.updated_at
: new Date().toISOString();
// Format date for display
const createdAtDisplay = new Date(createdAtValue).toLocaleDateString();
const updatedAtDisplay = new Date(updatedAtValue).toLocaleDateString();
if (props.type === 'edit' && isLoading) return <div>Loading...</div>;
if (props.type === 'edit' && fetchError)
return <div>Error loading event.</div>;
return (
<form className='flex flex-col gap-5 w-full' onSubmit={handleSubmit}>
<div className='grid grid-row-start:auto gap-4 sm:gap-8 w-full'>
<div className='h-full w-full mt-0 ml-2 mb-16 flex items-center max-sm:grid max-sm:grid-row-start:auto max-sm:mb-6 max-sm:mt-10 max-sm:ml-0'>
<div className='w-[100px] max-sm:w-full max-sm:flex max-sm:justify-center'>
<Logo colorType='monochrome' logoType='submark' width={50} />
</div>
<div className='items-center ml-auto mr-auto max-sm:mb-6 max-sm:w-full'>
<LabeledInput
type='text'
label='Event Name'
placeholder={props.type === 'create' ? 'New Event' : 'Event Name'}
name='eventName'
variantSize='big'
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className='w-0 sm:w-[50px]'></div>
</div>
<div className='grid grid-cols-4 gap-4 h-full w-full max-lg:grid-cols-2 max-sm:grid-cols-1'>
<div>
<TimePicker
dateLabel='start Time'
timeLabel='&nbsp;'
date={startDate}
setDate={setStartDate}
time={startTime}
setTime={setStartTime}
/>
</div>
<div>
<TimePicker
dateLabel='end Time'
timeLabel='&nbsp;'
date={endDate}
setDate={setEndDate}
time={endTime}
setTime={setEndTime}
/>
</div>
<div className='w-54'>
<LabeledInput
type='text'
label='Location'
placeholder='where is the event?'
name='eventLocation'
value={location}
onChange={(e) => setLocation(e.target.value)}
/>
</div>
<div className='flex flex-col gap-4'>
<div className='flex flex-row gap-2'>
<Label className='w-[70px]'>created:</Label>
<Label className='text-[var(--color-neutral-300)]'>
{createdAtDisplay}
</Label>
</div>
<div className='flex flex-row gap-2'>
<Label className='w-[70px]'>updated:</Label>
<p className='text-[var(--color-neutral-300)]'>
{updatedAtDisplay}
</p>
</div>
</div>
</div>
<div className='h-full w-full grid grid-cols-2 gap-4 max-sm:grid-cols-1'>
<div className='h-full w-full grid grid-flow-row gap-4'>
<div className='h-full w-full'>
<div className='flex flex-row gap-2'>
<Label>Organiser:</Label>
<Label className='text-[var(--color-neutral-300)]'>
{organiserValue}
</Label>
</div>
</div>
<div className='h-full w-full'>
<LabeledInput
type='text'
label='Event Description'
placeholder='What is the event about?'
name='eventDescription'
variantSize='textarea'
value={description}
onChange={(e) => setDescription(e.target.value)}
></LabeledInput>
</div>
</div>
<div className='h-full w-full'>
<Label>Participants</Label>
<UserSearchInput
selectedUsers={selectedParticipants}
addUserAction={(user) => {
setSelectedParticipants((current) =>
current.find((u) => u.id === user.id)
? current
: [...current, user],
);
}}
removeUserAction={(user) => {
setSelectedParticipants((current) =>
current.filter((u) => u.id !== user.id),
);
}}
/>
<div className='grid grid-cols-1 mt-3 sm:max-h-60 sm:grid-cols-2 sm:overflow-y-auto sm:mb-0'>
{selectedParticipants.map((user) => (
<ParticipantListEntry
key={user.id}
user={user}
status='PENDING'
/>
))}
</div>
</div>
</div>
<div className='flex flex-row gap-2 justify-end mt-4 mb-6'>
<div className='w-[20%] grid max-sm:w-[40%]'>
<Button
type='button'
variant='secondary'
onClick={() => {
router.back();
console.log('user aborted - no change in database');
}}
>
cancel
</Button>
</div>
<div className='w-[20%] grid max-sm:w-[40%]'>
<Button
type='submit'
variant='primary'
disabled={status === 'pending'}
>
{status === 'pending' ? 'Saving...' : 'save event'}
</Button>
</div>
</div>
{isSuccess && <p>Event created!</p>}
{error && <p className='text-red-500'>Error: {error.message}</p>}
</div>
</form>
);
};
export default EventForm;

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

@ -63,9 +63,9 @@ export default function Logo({
);
}
if (width === undefined || height === undefined) {
if (width === undefined && height === undefined) {
console.warn(
`Logo: 'width' and 'height' props are required by next/image for ${logoType} logo. Path: ${LOGO_BASE_PATH}logo_${colorType}_${logoType}_${theme}.${IMAGE_EXTENSION}`,
`Logo: 'width' or 'height' props are required by next/image for ${logoType} logo. Path: ${LOGO_BASE_PATH}logo_${colorType}_${logoType}_${theme}.${IMAGE_EXTENSION}`,
);
}
@ -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,35 @@
import Image from 'next/image';
import { Avatar } from '../ui/avatar';
import { useGetApiUserMe } from '@/generated/api/user/user';
import { User } from 'lucide-react';
import { Input } from '../ui/input';
export default function ProfilePictureUpload() {
const { data } = useGetApiUserMe();
return (
<>
<div className='grid grid-cols-1 gap-1'>
<span className='relative flex space-6'>
<Input
id='pic-upload'
type='file'
defaultValue={data?.data.user.image ?? undefined}
/>
<Avatar className='flex justify-center items-center ml-6 shadow-md border h-[36px] w-[36px]'>
{data?.data.user.image ? (
<Image
src={data?.data.user.image}
alt='Avatar'
width='20'
height='20'
/>
) : (
<User />
)}
</Avatar>
</span>
</div>
</>
);
}

View file

@ -0,0 +1,158 @@
'use client';
import type React from 'react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import {
Check,
ChevronDown,
User,
Bell,
Calendar,
Shield,
Palette,
} from 'lucide-react';
interface SettingsSection {
label: string;
value: string;
description: string;
icon: React.ComponentType<{ className?: string }>;
}
interface SettingsDropdownProps {
currentSection: string;
onSectionChange: (section: string) => void;
className?: string;
}
const settingsSections: SettingsSection[] = [
{
label: 'Account',
value: 'general',
description: 'Manage account details',
icon: User,
},
{
label: 'Notifications',
value: 'notifications',
description: 'Choose notification Preferences',
icon: Bell,
},
{
label: 'Calendar',
value: 'calendarAvailability',
description: 'Manage calendar display, availability and iCal integration',
icon: Calendar,
},
{
label: 'Privacy',
value: 'sharingPrivacy',
description: 'Control who can see your calendar and book time with you',
icon: Shield,
},
{
label: 'Appearance',
value: 'appearance',
description: 'Customize the look and feel of the application',
icon: Palette,
},
];
export function SettingsDropdown({
currentSection,
onSectionChange,
className,
}: SettingsDropdownProps) {
const [open, setOpen] = useState(false);
const currentSectionData = settingsSections.find(
(section) => section.value === currentSection,
);
const CurrentIcon = currentSectionData?.icon || User;
const handleSelect = (value: string) => {
onSectionChange(value);
setOpen(false);
};
return (
<div className={cn('w-full max-w-md', className)}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant='outline_muted'
role='combobox'
aria-expanded={open}
className='w-full justify-between bg-popover text-text h-auto py-3'
>
<div className='flex items-center gap-3'>
<CurrentIcon className='h-4 w-4 text-muted-foreground' />
<div className='flex flex-col items-start text-left'>
<span className='font-medium'>{currentSectionData?.label}</span>
<p className='text-xs text-muted-foreground text-wrap'>
{currentSectionData?.description}
</p>
</div>
</div>
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-full p-0' align='start'>
<Command>
<CommandInput placeholder='Search settings...' />
<CommandList>
<CommandEmpty>No settings found.</CommandEmpty>
<CommandGroup>
{settingsSections.map((section) => {
const Icon = section.icon;
return (
<CommandItem
key={section.value}
value={section.value}
onSelect={() => handleSelect(section.value)}
className='flex items-center justify-between p-3'
>
<div className='flex items-center gap-3'>
<Icon className='h-4 w-4 text-muted-foreground' />
<div className='flex flex-col'>
<span className='font-medium'>{section.label}</span>
<p className='text-xs text-muted-foreground text-wrap'>
{section.description}
</p>
</div>
</div>
<Check
className={cn(
'ml-2 h-4 w-4',
currentSection === section.value
? 'opacity-100'
: 'opacity-0',
)}
/>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
}

View file

@ -0,0 +1,514 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { ScrollableSettingsWrapper } from '@/components/wrappers/settings-scroll';
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { SettingsDropdown } from '@/components/misc/settings-dropdown';
import { useRouter } from 'next/navigation';
import { useGetApiUserMe } from '@/generated/api/user/user';
import { ThemePicker } from './theme-picker';
import LabeledInput from '../custom-ui/labeled-input';
import { GroupWrapper } from '../wrappers/group-wrapper';
import { Avatar } from '../ui/avatar';
import Image from 'next/image';
import { User } from 'lucide-react';
import ProfilePictureUpload from './profile-picture-upload';
export default function SettingsPage() {
const router = useRouter();
const [currentSection, setCurrentSection] = useState('general');
const { data } = useGetApiUserMe();
const renderSettingsContent = () => {
switch (currentSection) {
case 'general':
return (
<Card className='h-full flex flex-col border-0 shadow-none rounded-none'>
<ScrollableSettingsWrapper>
<CardHeader>
<CardTitle>Account Settings</CardTitle>
</CardHeader>
<CardContent className='space-y-6 mt-2'>
<GroupWrapper title='General Settings'>
<div className='space-y-4'>
<GroupWrapper>
<div className='flex items-center justify-evenly sm:flex-row flex-col gap-6'>
<div>
<LabeledInput
label='First Name'
type='text'
placeholder='First Name'
defaultValue={data?.data.user.first_name ?? ''}
></LabeledInput>
</div>
<div>
<LabeledInput
label='Last Name'
type='text'
placeholder='Last Name'
defaultValue={data?.data.user.last_name ?? ''}
></LabeledInput>
</div>
</div>
</GroupWrapper>
<div className='space-y-2'>
<LabeledInput
label='Display Name'
type='text'
placeholder='Display Name'
defaultValue={data?.data.user.name}
></LabeledInput>
</div>
<div className='space-y-2 space-b-2'>
<LabeledInput
type='email'
label='Email Address'
placeholder='Your E-Mail'
defaultValue={data?.data.user.email ?? ''}
></LabeledInput>
<span className='text-sm text-muted-foreground'>
Email might be managed by your SSO provider.
</span>
</div>
</div>
</GroupWrapper>
<GroupWrapper title='Reset Password'>
<div className='flex items-center justify-evenly sm:flex-row flex-col gap-6'>
<div>
<LabeledInput
type='password'
label='Current Password'
placeholder='Current Password'
defaultValue={data?.data.user.first_name ?? ''}
></LabeledInput>
</div>
<div>
<LabeledInput
type='password'
label='New Password'
placeholder='New Password'
defaultValue={data?.data.user.first_name ?? ''}
></LabeledInput>
</div>
<div>
<LabeledInput
type='password'
label='Repeat Password'
placeholder='Repeat Password'
defaultValue={data?.data.user.first_name ?? ''}
></LabeledInput>
</div>
</div>
</GroupWrapper>
<GroupWrapper title='Profile Picture'>
<div className='space-y-2 grid grid-cols-[1fr_auto]'>
<ProfilePictureUpload />
</div>
</GroupWrapper>
<GroupWrapper title='Regional Settings'>
<div className='space-y-2 grid sm:grid-cols-[1fr_auto] sm:flex-row gap-4'>
<div className='grid gap-1'>
<LabeledInput
type='text'
label='Timezone'
placeholder='Europe/Berlin'
defaultValue={data?.data.user.timezone}
></LabeledInput>
</div>
<div>
<div className='grid gap-1'>
<Label htmlFor='language'>Language</Label>
<Select>
<SelectTrigger id='language'>
<SelectValue placeholder='Select language' />
</SelectTrigger>
<SelectContent>
<SelectItem value='en'>English</SelectItem>
<SelectItem value='de'>German</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</GroupWrapper>
<div className='flex items-center justify-evenly sm:flex-row flex-col gap-6'>
<Button variant='secondary'>Delete Account</Button>
<span className='text-sm text-muted-foreground pt-1'>
Permanently delete your account and all associated data.
</span>
</div>
</CardContent>
</ScrollableSettingsWrapper>
</Card>
);
case 'notifications':
return (
<Card className='h-full flex flex-col border-0 shadow-none rounded-none'>
<ScrollableSettingsWrapper>
<CardHeader>
<CardTitle>Notification Preferences</CardTitle>
</CardHeader>
<CardContent className='space-y-6'>
<div className='flex items-center justify-between space-x-2 p-3 rounded-md border'>
<Label
htmlFor='masterEmailNotifications'
className='font-normal'
>
Enable All Email Notifications
</Label>
<Switch id='masterEmailNotifications' />
</div>
<div className='space-y-4 pl-2 border-l-2 ml-2'>
<div className='flex items-center justify-between space-x-2'>
<Label htmlFor='newMeetingBookings' className='font-normal'>
New Meeting Bookings
</Label>
<Switch id='newMeetingBookings' />
</div>
<div className='flex items-center justify-between space-x-2'>
<Label
htmlFor='meetingConfirmations'
className='font-normal'
>
Meeting Confirmations/Cancellations
</Label>
<Switch id='meetingConfirmations' />
</div>
<div className='flex items-center justify-between space-x-2'>
<Label
htmlFor='enableMeetingReminders'
className='font-normal'
>
Meeting Reminders
</Label>
<Switch id='enableMeetingReminders' />
</div>
<div className='space-y-2 pl-6'>
<Label htmlFor='remindBefore'>Remind me before</Label>
<Select>
<SelectTrigger id='remindBefore'>
<SelectValue placeholder='Select reminder time' />
</SelectTrigger>
<SelectContent>
<SelectItem value='15m'>15 minutes</SelectItem>
<SelectItem value='30m'>30 minutes</SelectItem>
<SelectItem value='1h'>1 hour</SelectItem>
<SelectItem value='1d'>1 day</SelectItem>
</SelectContent>
</Select>
</div>
<div className='flex items-center justify-between space-x-2'>
<Label htmlFor='friendRequests' className='font-normal'>
Friend Requests
</Label>
<Switch id='friendRequests' />
</div>
<div className='flex items-center justify-between space-x-2'>
<Label htmlFor='groupUpdates' className='font-normal'>
Group Invitations/Updates
</Label>
<Switch id='groupUpdates' />
</div>
</div>
</CardContent>
</ScrollableSettingsWrapper>
</Card>
);
case 'calendarAvailability':
return (
<Card className='h-full flex flex-col border-0 shadow-none rounded-none'>
<ScrollableSettingsWrapper>
<CardHeader>
<CardTitle>Calendar & Availability</CardTitle>
</CardHeader>
<CardContent className='space-y-6'>
<fieldset className='space-y-4 p-4 border rounded-md'>
<legend className='text-sm font-medium px-1'>Display</legend>
<div className='space-y-2'>
<Label htmlFor='defaultCalendarView'>
Default Calendar View
</Label>
<Select>
<SelectTrigger id='defaultCalendarView'>
<SelectValue placeholder='Select view' />
</SelectTrigger>
<SelectContent>
<SelectItem value='day'>Day</SelectItem>
<SelectItem value='week'>Week</SelectItem>
<SelectItem value='month'>Month</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='weekStartsOn'>Week Starts On</Label>
<Select>
<SelectTrigger id='weekStartsOn'>
<SelectValue placeholder='Select day' />
</SelectTrigger>
<SelectContent>
<SelectItem value='sunday'>Sunday</SelectItem>
<SelectItem value='monday'>Monday</SelectItem>
</SelectContent>
</Select>
</div>
<div className='flex items-center justify-between space-x-2'>
<Label htmlFor='showWeekends' className='font-normal'>
Show Weekends
</Label>
<Switch id='showWeekends' defaultChecked />
</div>
</fieldset>
<fieldset className='space-y-4 p-4 border rounded-md'>
<legend className='text-sm font-medium px-1'>
Availability
</legend>
<div className='space-y-2'>
<Label>Working Hours</Label>
<span className='text-sm text-muted-foreground'>
Define your typical available hours (e.g., Monday-Friday,
9 AM - 5 PM).
</span>
<Button variant='outline_muted' size='sm'>
Set Working Hours
</Button>
</div>
<div className='space-y-2'>
<Label htmlFor='minNoticeBooking'>
Minimum Notice for Bookings
</Label>
<span className='text-sm text-muted-foreground'>
Min time before a booking can be made.
</span>
<div className='space-y-2'>
<Input
id='bookingWindow'
type='text'
placeholder='e.g., 1h'
/>
</div>
</div>
<div className='space-y-2'>
<Label htmlFor='bookingWindow'>
Booking Window (days in advance)
</Label>
<span className='text-sm text-muted-foreground'>
Max time in advance a booking can be made.
</span>
<Input
id='bookingWindow'
type='number'
placeholder='e.g., 30d'
/>
</div>
</fieldset>
<fieldset className='space-y-4 p-4 border rounded-md'>
<legend className='text-sm font-medium px-1'>
iCalendar Integration
</legend>
<div className='space-y-2'>
<Label htmlFor='icalImport'>Import iCal Feed URL</Label>
<Input
id='icalImport'
type='url'
placeholder='https://calendar.example.com/feed.ics'
/>
<Button size='sm' className='mt-1'>
Add Feed
</Button>
</div>
<div className='space-y-2'>
<Label>Export Your Calendar</Label>
<Button variant='outline_muted' size='sm'>
Get iCal Export URL
</Button>
<Button variant='outline_muted' size='sm' className='ml-2'>
Download .ics File
</Button>
</div>
</fieldset>
</CardContent>
</ScrollableSettingsWrapper>
</Card>
);
case 'sharingPrivacy':
return (
<Card className='h-full flex flex-col border-0 shadow-none rounded-none'>
<ScrollableSettingsWrapper>
<CardHeader>
<CardTitle>Sharing & Privacy</CardTitle>
</CardHeader>
<CardContent className='space-y-6'>
<div className='space-y-2'>
<Label htmlFor='defaultVisibility'>
Default Calendar Visibility
</Label>
<Select>
<SelectTrigger id='defaultVisibility'>
<SelectValue placeholder='Select visibility' />
</SelectTrigger>
<SelectContent>
<SelectItem value='private'>
Private (Only You)
</SelectItem>
<SelectItem value='freebusy'>
Free/Busy for Friends
</SelectItem>
<SelectItem value='fulldetails'>
Full Details for Friends
</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='whoCanSeeFull'>
Who Can See Your Full Calendar Details?
</Label>
<span className='text-sm text-muted-foreground'>
(Override for Default Visibility)
<br />
<span className='text-sm text-muted-foreground'>
This setting will override the default visibility for your
calendar. You can set specific friends or groups to see
your full calendar details.
</span>
</span>
<Select>
<SelectTrigger id='whoCanSeeFull'>
<SelectValue placeholder='Select audience' />
</SelectTrigger>
<SelectContent>
<SelectItem value='me'>Only Me</SelectItem>
<SelectItem value='friends'>My Friends</SelectItem>
<SelectItem value='specific'>
Specific Friends/Groups (manage separately)
</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='whoCanBook'>
Who Can Book Time With You?
</Label>
<Select>
<SelectTrigger id='whoCanBook'>
<SelectValue placeholder='Select audience' />
</SelectTrigger>
<SelectContent>
<SelectItem value='none'>No One</SelectItem>
<SelectItem value='friends'>My Friends</SelectItem>
<SelectItem value='specific'>
Specific Friends/Groups (manage separately)
</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label>Blocked Users</Label>
<Button variant='outline_muted'>Manage Blocked Users</Button>
<span className='text-sm text-muted-foreground'>
Prevent specific users from seeing your calendar or booking
time.
</span>
</div>
</CardContent>
</ScrollableSettingsWrapper>
</Card>
);
case 'appearance':
return (
<Card className='h-full flex flex-col border-0 shadow-none rounded-none'>
<ScrollableSettingsWrapper>
<CardHeader>
<CardTitle>Appearance</CardTitle>
</CardHeader>
<CardContent className='space-y-6'>
<div className='space-y-2'>
<Label htmlFor='theme'>Theme</Label>
<ThemePicker />
</div>
<div className='space-y-2'>
<Label htmlFor='dateFormat'>Date Format</Label>
<Select>
<SelectTrigger id='dateFormat'>
<SelectValue placeholder='Select date format' />
</SelectTrigger>
<SelectContent>
<SelectItem value='ddmmyyyy'>DD/MM/YYYY</SelectItem>
<SelectItem value='mmddyyyy'>MM/DD/YYYY</SelectItem>
<SelectItem value='yyyymmdd'>YYYY-MM-DD</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='timeFormat'>Time Format</Label>
<Select>
<SelectTrigger id='timeFormat'>
<SelectValue placeholder='Select time format' />
</SelectTrigger>
<SelectContent>
<SelectItem value='24h'>24-hour</SelectItem>
<SelectItem value='12h'>12-hour</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</ScrollableSettingsWrapper>
</Card>
);
default:
return null;
}
};
return (
<div className='fixed inset-0 flex items-center justify-center p-4 bg-background/50 backdrop-blur-sm'>
<div className='rounded-lg border bg-card text-card-foreground shadow-xl max-w-[700px] w-full h-auto max-h-[calc(100vh-2rem)] flex flex-col'>
<div className='p-6 border-b'>
<div className='flex items-center justify-between mb-4'>
<h1 className='text-2xl font-semibold'>Settings</h1>
</div>
<SettingsDropdown
currentSection={currentSection}
onSectionChange={setCurrentSection}
/>
</div>
<div className='flex-grow overflow-auto'>{renderSettingsContent()}</div>
<div>
<CardFooter className='border-t h-[60px] flex content-center justify-between'>
<Button onClick={() => router.back()} variant='secondary'>
Exit
</Button>
<Button variant='primary'>Save Changes</Button>
</CardFooter>
</div>
</div>
</div>
);
}

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,162 @@
/*
USAGE:
import { toast } from 'sonner';
import { ToastInner } from '@/components/misc/toast-inner';
import { Button } from '@/components/ui/button';
<Button
variant='outline_primary'
onClick={() =>
toast.custom(
(t) => (
<ToastInner
toastId={t}
title=''
description=''
onAction={() => console.log('on Action')} //No Button shown if this is null
variant=''default' | 'success' | 'error' | 'info' | 'warning' | 'notification''
buttonText=[No Button shown if this is null]
iconName=[Any Icon Name from Lucide in UpperCamelCase or default if null]
/>
),
{
duration: 5000,
},
)
}
>
Show Toast
</Button>
*/
'use client';
import { toast } from 'sonner';
import { X } from 'lucide-react';
import React from 'react';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import * as Icons from 'lucide-react';
interface ToastInnerProps {
title: string;
description?: string;
buttonText?: string;
onAction?: () => void;
toastId: string | number;
variant?:
| 'default'
| 'success'
| 'error'
| 'info'
| 'warning'
| 'notification';
iconName?: keyof typeof Icons;
closeOnAction?: boolean;
}
const variantConfig = {
default: {
bgColor: 'bg-toaster-default-bg',
defaultIcon: 'Info',
},
success: {
bgColor: 'bg-toaster-success-bg',
defaultIcon: 'CheckCircle',
},
error: {
bgColor: 'bg-toaster-error-bg',
defaultIcon: 'XCircle',
},
info: {
bgColor: 'bg-toaster-info-bg',
defaultIcon: 'Info',
},
warning: {
bgColor: 'bg-toaster-warning-bg',
defaultIcon: 'AlertTriangle',
},
notification: {
bgColor: 'bg-toaster-notification-bg',
defaultIcon: 'BellRing',
},
};
export const ToastInner: React.FC<ToastInnerProps> = ({
title,
description,
buttonText,
onAction,
toastId,
variant = 'default',
iconName,
closeOnAction = true,
}) => {
const bgColor = variantConfig[variant].bgColor;
// fallback to variant's default icon if iconName is not provided
const iconKey = (iconName ||
variantConfig[variant].defaultIcon) as keyof typeof Icons;
const Icon = Icons[iconKey] as React.ComponentType<Icons.LucideProps>;
return (
<div className={`relative sm:w-120 rounded p-4 ${bgColor} select-none`}>
{/* Close Button */}
<button
onClick={() => toast.dismiss(toastId)}
className='absolute top-2 right-2 cursor-pointer'
aria-label='Close notification'
>
<X className='h-4 w-4 text-neutral-600' />
</button>
<div
className={`grid ${
variant === 'default'
? 'grid-cols-[auto_130px] max-sm:grid-cols-[auto_90px]'
: 'grid-cols-[40px_auto_130px] max-sm:grid-cols-[40px_auto_90px]'
} gap-4 items-center`}
>
{variant !== 'default' && (
<div className='flex items-center justify-center'>
<Icon className='text-text-alt' size={40} />
</div>
)}
{/* Text Content */}
<div className='grid gap-1'>
<h6 className='text-text-alt'>{title}</h6>
{description && (
<Label className='text-text-alt'>{description}</Label>
)}
</div>
{/* Action Button */}
<div className='flex justify-center'>
{onAction && buttonText && (
<Button
variant={'secondary'}
className='w-full mr-2'
onClick={() => {
onAction();
if (closeOnAction) {
toast.dismiss(toastId);
}
}}
>
{buttonText}
</Button>
)}
</div>
</div>
</div>
);
};

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 text-[12px]'>
{data?.data.user.email}
</div>
</div>
);
}

View file

@ -0,0 +1,55 @@
'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 />
<Link href='/settings'>
<DropdownMenuItem>Settings</DropdownMenuItem>
</Link>
<DropdownMenuSeparator />
<Link href='/logout'>
<DropdownMenuItem>Logout</DropdownMenuItem>
</Link>
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -0,0 +1,95 @@
'use client';
import * as React from 'react';
import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { useGetApiSearchUser } from '@/generated/api/search/search';
import zod from 'zod/v4';
import { PublicUserSchema } from '@/app/api/user/validation';
type User = zod.output<typeof PublicUserSchema>;
export function UserSearchInput({
addUserAction,
removeUserAction,
selectedUsers,
}: {
addUserAction: (user: User) => void;
removeUserAction: (user: User) => void;
selectedUsers: User[];
}) {
const [userSearch, setUserSearch] = React.useState('');
const [open, setOpen] = React.useState(false);
const { data: searchUserData } = useGetApiSearchUser({ query: userSearch });
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant='outline_muted'
role='combobox'
aria-expanded={open}
className='w-[200px] justify-between'
>
{'Select user...'}
<ChevronsUpDownIcon className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[200px] p-0'>
<Command shouldFilter={false}>
<CommandInput
placeholder='Search user...'
value={userSearch}
onValueChange={setUserSearch}
/>
<CommandList>
<CommandEmpty>No users found.</CommandEmpty>
<CommandGroup>
{searchUserData?.data.users?.map((user) => {
const isSelected = selectedUsers.some((u) => u.id === user.id);
return (
<CommandItem
key={user.id}
value={user.id}
onSelect={() => {
if (isSelected) {
removeUserAction(user);
} else {
addUserAction(user);
}
setOpen(false);
}}
>
<CheckIcon
className={cn(
'mr-2 h-4 w-4',
isSelected ? 'opacity-100' : 'opacity-0',
)}
/>
{user.name}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

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,86 @@
'use client';
import * as React from 'react';
import { ChevronDownIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
export default function TimePicker({
dateLabel = 'Date',
timeLabel = 'Time',
date,
setDate,
time,
setTime,
}: {
dateLabel?: string;
timeLabel?: string;
date?: Date;
setDate?: (date: Date | undefined) => void;
time?: string;
setTime?: (time: string) => void;
}) {
const [open, setOpen] = React.useState(false);
return (
<div className='flex gap-4'>
<div className='flex flex-col gap-3'>
<Label htmlFor='date' className='px-1'>
{dateLabel}
</Label>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant='calendar' id='date'>
{date ? date.toLocaleDateString() : 'Select date'}
<ChevronDownIcon />
</Button>
</PopoverTrigger>
<PopoverContent className='w-auto overflow-hidden p-0' align='start'>
<Calendar
mode='single'
selected={date}
captionLayout='dropdown'
onSelect={(d) => {
setDate?.(d);
setOpen(false);
}}
modifiers={{
today: new Date(),
}}
modifiersClassNames={{
today: 'bg-secondary text-secondary-foreground rounded-full',
}}
classNames={{
day: 'text-center hover:bg-gray-500 hover:rounded-md',
}}
weekStartsOn={1} // Set Monday as the first day of the week
startMonth={new Date(new Date().getFullYear() - 10, 0)}
endMonth={new Date(new Date().getFullYear() + 14, 12)}
/>
</PopoverContent>
</Popover>
</div>
<div className='flex flex-col gap-3'>
<Label htmlFor='time' className='px-1'>
{timeLabel}
</Label>
<Input
type='time'
id='time'
step='60'
value={time}
onChange={(e) => setTime?.(e.target.value)}
className='bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none'
/>
</div>
</div>
);
}

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

@ -5,7 +5,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"radius-lg inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-button transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"radius-lg inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-button transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-nonef",
{
variants: {
variant: {
@ -21,8 +21,13 @@ const buttonVariants = cva(
'bg-background border-2 text-text shadow-xs hover:bg-secondary border-secondary hover:border-background-reversed active:bg-active-secondary disabled:bg-disabled-secondary',
outline_muted:
'bg-background border-2 text-text shadow-xs hover:bg-muted border-muted hover:border-background-reversed active:bg-active-muted disabled:bg-disabled-muted',
link: 'text-text underline-offset-4 hover:underline',
calendar:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 w-32 justify-between font-normal',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
destructive:
'bg-destructive text-text shadow-xs hover:bg-hover-destructive active:bg-active-destructive disabled:bg-disabled-destructive',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',

View file

@ -0,0 +1,213 @@
'use client';
import * as React from 'react';
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from 'lucide-react';
import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker';
import { cn } from '@/lib/utils';
import { Button, buttonVariants } from '@/components/ui/button';
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = 'label',
buttonVariant = 'ghost',
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>['variant'];
}) {
const defaultClassNames = getDefaultClassNames();
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
'bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className,
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString('default', { month: 'short' }),
...formatters,
}}
classNames={{
root: cn('w-fit', defaultClassNames.root),
months: cn(
'flex gap-4 flex-col md:flex-row relative',
defaultClassNames.months,
),
month: cn('flex flex-col w-full gap-4', defaultClassNames.month),
nav: cn(
'flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between',
defaultClassNames.nav,
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
defaultClassNames.button_previous,
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
defaultClassNames.button_next,
),
month_caption: cn(
'flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)',
defaultClassNames.month_caption,
),
dropdowns: cn(
'w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5',
defaultClassNames.dropdowns,
),
dropdown_root: cn(
'relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md',
defaultClassNames.dropdown_root,
),
dropdown: cn(
'bg-[var(--color-basecl)] absolute inset-0 opacity-0',
defaultClassNames.dropdown,
),
caption_label: cn(
'select-none font-medium',
captionLayout === 'label'
? 'text-sm'
: 'rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5',
defaultClassNames.caption_label,
),
table: 'w-full border-collapse',
weekdays: cn('flex', defaultClassNames.weekdays),
weekday: cn(
'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none',
defaultClassNames.weekday,
),
week: cn('flex w-full mt-2', defaultClassNames.week),
week_number_header: cn(
'select-none w-(--cell-size)',
defaultClassNames.week_number_header,
),
week_number: cn(
'text-[0.8rem] select-none text-muted-foreground',
defaultClassNames.week_number,
),
day: cn(
'relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none',
defaultClassNames.day,
),
range_start: cn(
'rounded-l-md bg-accent',
defaultClassNames.range_start,
),
range_middle: cn('rounded-none', defaultClassNames.range_middle),
range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
today: cn(
'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none',
defaultClassNames.today,
),
outside: cn(
'text-muted-foreground aria-selected:text-muted-foreground',
defaultClassNames.outside,
),
disabled: cn(
'text-muted-foreground opacity-50',
defaultClassNames.disabled,
),
hidden: cn('invisible', defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot='calendar'
ref={rootRef}
className={cn(className)}
{...props}
/>
);
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === 'left') {
return (
<ChevronLeftIcon className={cn('size-4', className)} {...props} />
);
}
if (orientation === 'right') {
return (
<ChevronRightIcon
className={cn('size-4', className)}
{...props}
/>
);
}
return (
<ChevronDownIcon className={cn('size-4', className)} {...props} />
);
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className='flex size-(--cell-size) items-center justify-center text-center'>
{children}
</div>
</td>
);
},
...components,
}}
{...props}
/>
);
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<Button
ref={ref}
variant='ghost'
size='icon'
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
'data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70',
defaultClassNames.day,
className,
)}
{...props}
/>
);
}
export { Calendar, CalendarDayButton };

View file

@ -22,7 +22,7 @@ function Card({ className, ...props }: React.ComponentProps<'div'>) {
/* Outline */
'',
/* Shadow */
'shadow-sm',
'shadow-[4px_4px_9px_9px_rgba(0,0,0,0.25)]',
/* Opacity */
'',
/* Scaling */
@ -126,7 +126,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-footer'
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
className={cn('flex items-center px-6', className)}
{...props}
/>
);

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

@ -0,0 +1,184 @@
'use client';
import * as React from 'react';
import { Command as CommandPrimitive } from 'cmdk';
import { SearchIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot='command'
className={cn(
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
className,
)}
{...props}
/>
);
}
function CommandDialog({
title = 'Command Palette',
description = 'Search for a command to run...',
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
}) {
return (
<Dialog {...props}>
<DialogHeader className='sr-only'>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn('overflow-hidden p-0', className)}
showCloseButton={showCloseButton}
>
<Command className='[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5'>
{children}
</Command>
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot='command-input-wrapper'
className='flex h-9 items-center gap-2 border-b px-3'
>
<SearchIcon className='size-4 shrink-0 opacity-50' />
<CommandPrimitive.Input
data-slot='command-input'
className={cn(
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
</div>
);
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot='command-list'
className={cn(
'max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto',
className,
)}
{...props}
/>
);
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot='command-empty'
className='py-6 text-center text-sm'
{...props}
/>
);
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot='command-group'
className={cn(
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
className,
)}
{...props}
/>
);
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot='command-separator'
className={cn('bg-border -mx-1 h-px', className)}
{...props}
/>
);
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot='command-item'
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot='command-shortcut'
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className,
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View file

@ -0,0 +1,143 @@
'use client';
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { XIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot='dialog' {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot='dialog-trigger' {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot='dialog-portal' {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot='dialog-close' {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot='dialog-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 DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot='dialog-portal'>
<DialogOverlay />
<DialogPrimitive.Content
data-slot='dialog-content'
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot='dialog-close'
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground 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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className='sr-only'>Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='dialog-header'
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='dialog-footer'
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className,
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot='dialog-title'
className={cn('text-lg leading-none font-semibold', className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot='dialog-description'
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View file

@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
data-slot='input'
className={cn(
/* Text */
'text-text-input selection:text-text md:text-sm file:text-destructive file:text-sm placeholder:text-text-muted-input',
'text-text-input selection:text-text file:text-destructive file:text-sm placeholder:text-text-muted-input',
/* Background */
'bg-transparent selection:bg-muted-input file:bg-transparent',
/* Border */
@ -41,4 +41,45 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
);
}
export { Input };
function Textarea({
className,
rows,
...props
}: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot='input'
rows={rows}
className={cn(
/* Text */
'text-text-input selection:text-text placeholder:text-text-muted-input',
/* Background */
'bg-transparent selection:bg-muted-input',
/* Border */
'rounded-md border border-input focus-visible:border-ring aria-invalid:border-destructive',
/* Font */
'',
/* Cursor */
'disabled:pointer-events-none disabled:cursor-not-allowed',
/* Ring */
'focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
/* Outline */
'outline-none',
/* Shadow */
'shadow-md transition-[color,box-shadow]',
/* Opacity */
'disabled:opacity-50',
/* Scaling */
'h-32 w-full min-w-0', // Bigger height for textarea
/* Spacing */
'px-3 py-2',
/* Alignment */
'',
className,
)}
{...props}
/>
);
}
export { Input, Textarea };

View file

@ -5,16 +5,21 @@ import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '@/lib/utils';
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
type LabelProps = React.ComponentProps<typeof LabelPrimitive.Root> & {
size?: 'default' | 'small' | 'large';
};
function Label({ className, size = 'default', ...props }: LabelProps) {
return (
<LabelPrimitive.Root
data-slot='label'
className={cn(
/* Text */
'text-sm',
size === 'small'
? 'text-sm'
: size === 'large'
? 'text-xl'
: 'text-base',
/* Background */
'',
/* Border */

View file

@ -0,0 +1,48 @@
'use client';
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '@/lib/utils';
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot='popover' {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot='popover-trigger' {...props} />;
}
function PopoverContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot='popover-content'
align={align}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot='popover-anchor' {...props} />;
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

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

@ -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,54 @@
'use client';
import { useTheme } from 'next-themes';
import { Toaster as Sonner, ToasterProps } from 'sonner';
import React from 'react';
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme();
const [shouldExpand, setShouldExpand] = React.useState(false);
React.useEffect(() => {
const mediaQuery = window.matchMedia('(min-width: 600px)');
const handleScreenSizeChange = () => {
setShouldExpand(mediaQuery.matches);
};
handleScreenSizeChange(); // set initial value
mediaQuery.addEventListener('change', handleScreenSizeChange);
return () =>
mediaQuery.removeEventListener('change', handleScreenSizeChange);
}, []);
return (
<Sonner
theme={theme as ToasterProps['theme']}
richColors={true}
className='toaster group'
toastOptions={{
style: {
backgroundColor: 'var(--color-neutral-150)',
color: 'var(--color-text-alt)',
borderRadius: 'var(--radius)',
},
cancelButtonStyle: {
backgroundColor: 'var(--color-secondary)',
color: 'var(--color-text-alt)',
},
actionButtonStyle: {
backgroundColor: 'var(--color-secondary)',
color: 'var(--color-text-alt)',
},
}}
swipeDirections={['left', 'right']}
closeButton={true}
expand={shouldExpand}
{...props}
/>
);
};
export { Toaster };

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 @@
import { cn } from '@/lib/utils';
import type * as React from 'react';
interface ScrollableSettingsWrapperProps {
className?: string;
title?: string;
children: React.ReactNode;
}
export function GroupWrapper({
className,
title,
children,
}: ScrollableSettingsWrapperProps) {
return (
<fieldset
className={cn('space-t-4 p-4 border rounded-md shadow-md', className)}
>
<legend className='text-sm font-medium px-1'>{title}</legend>
{children}
</fieldset>
);
}

View file

@ -1,16 +1,16 @@
import React from 'react';
import { cn } from '@/lib/utils';
import type * as React from 'react';
interface ScrollableContentWrapperProps {
children: React.ReactNode;
interface ScrollableSettingsWrapperProps {
className?: string;
children: React.ReactNode;
}
export const ScrollableSettingsWrapper: React.FC<
ScrollableContentWrapperProps
> = ({ children, className = '' }) => {
export function ScrollableSettingsWrapper({
className,
children,
}: ScrollableSettingsWrapperProps) {
return (
<div className={`h-[500px] overflow-y-auto space-y-2 ${className}`}>
{children}
</div>
<div className={cn('overflow-y-auto h-full', className)}>{children}</div>
);
};
}

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": {

2482
yarn.lock

File diff suppressed because it is too large Load diff