Compare commits

..

143 commits

Author SHA1 Message Date
4c6e66c1a2 test: integrate cypress
Some checks failed
container-scan / Container Scan (pull_request) Successful in 5m58s
docker-build / docker (pull_request) Successful in 5m57s
tests / Tests (pull_request) Failing after 7m21s
2025-06-25 14:24:23 +02:00
3a4695bc03 Merge pull request 'feat: create sidebar and header' (#103)
All checks were successful
container-scan / Container Scan (push) Successful in 2m44s
docker-build / docker (push) Successful in 6m40s
Reviewed-on: #103
Reviewed-by: Dominik <mail@dominikstahl.dev>
2025-06-25 10:39:17 +00:00
8bee6ede3f refactor: remove unused imports from notification button and user dropdown components
All checks were successful
container-scan / Container Scan (pull_request) Successful in 5m1s
docker-build / docker (pull_request) Successful in 5m31s
2025-06-25 12:08:19 +02:00
1d9ab84047 feat: enhance header with notification buttons and user dropdown
- Updated header component to include notification buttons with icons.
- Introduced a new NavUser component for user-related actions in the sidebar.
- Added NotificationDot component for visual notification indicators.
- Created UserCard component to display user information.
- Implemented UserDropdown for user settings and logout functionality.
- Added Avatar component for user images with fallback support.
- Refactored Sheet and Tooltip components for consistency and improved styling.
- Introduced QueryProvider for managing React Query context.
- Updated SidebarProvider to use custom sidebar implementation.
- Enhanced mobile detection hook for better responsiveness.
- Updated dependencies in yarn.lock for new features and fixes.

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

feat: add AppSidebar component with collapsible functionality and sidebar menu

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

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

- Added @radix-ui/react-collapsible package to manage collapsible UI elements.
2025-06-25 12:08:19 +02:00
21eff651e8 fix(deps): update dependency next to v15.4.0-canary.95
Some checks failed
container-scan / Container Scan (push) Failing after 4m10s
docker-build / docker (push) Successful in 1m27s
2025-06-25 00:01:36 +00:00
d62e954348 chore(deps): update dependency @types/node to v22.15.33
All checks were successful
container-scan / Container Scan (push) Successful in 4m19s
docker-build / docker (push) Successful in 1m19s
2025-06-24 17:01:52 +00:00
2889424bfb fix(deps): update dependency next to v15.4.0-canary.94
All checks were successful
container-scan / Container Scan (push) Successful in 4m11s
docker-build / docker (push) Successful in 1m23s
2025-06-24 00:01:37 +00:00
3ee0dcf950 fix(deps): update dependency next to v15.4.0-canary.93
All checks were successful
container-scan / Container Scan (push) Successful in 3m35s
docker-build / docker (push) Successful in 1m4s
2025-06-23 11:01:37 +00:00
c98a72f2f1 Merge pull request 'feat(api): implement missing user update and delete endpoints' (#102)
All checks were successful
container-scan / Container Scan (push) Successful in 4m58s
docker-build / docker (push) Successful in 7m17s
Reviewed-on: #102
Reviewed-by: Maximilian Liebmann <lima@noreply.git.dominikstahl.dev>
2025-06-23 09:37:13 +00:00
29f2a01ac6
style: format code
All checks were successful
container-scan / Container Scan (pull_request) Successful in 3m10s
docker-build / docker (pull_request) Successful in 7m25s
2025-06-23 10:45:56 +02:00
4cf5ce26ff
feat(api): implement DELETE method for /api/user/me endpoint
Some checks failed
container-scan / Container Scan (pull_request) Failing after 32s
docker-build / docker (pull_request) Successful in 7m22s
2025-06-23 10:44:26 +02:00
16b878a2e9
feat(api): stricter user data api types checking 2025-06-23 10:40:28 +02:00
280fa57e45
feat(api): implement /api/user/me/password endpoint
Some checks failed
container-scan / Container Scan (pull_request) Failing after 33s
docker-build / docker (pull_request) Failing after 5m11s
add an endpoint to allow the user to change his password
2025-06-23 10:00:19 +02:00
be1502a4ac fix(deps): update dependency next to v15.4.0-canary.92
Some checks failed
container-scan / Container Scan (push) Failing after 4m35s
docker-build / docker (push) Successful in 1m30s
2025-06-23 00:00:52 +00:00
f240bf525d fix(deps): update dependency @auth/prisma-adapter to v2.10.0
All checks were successful
container-scan / Container Scan (push) Successful in 12m38s
docker-build / docker (push) Successful in 2m20s
2025-06-22 14:02:45 +00:00
525b8597f2 fix(deps): update dependency next-auth to v5.0.0-beta.29
All checks were successful
container-scan / Container Scan (push) Successful in 11m8s
docker-build / docker (push) Successful in 13m15s
2025-06-22 13:01:47 +00:00
0da8e35b9b fix(deps): update dependency next to v15.4.0-canary.91
All checks were successful
container-scan / Container Scan (push) Successful in 11m26s
docker-build / docker (push) Successful in 2m9s
2025-06-22 00:03:20 +00:00
cd1ad5dbc4 fix(deps): update dependency @tanstack/react-query to v5.81.2
All checks were successful
container-scan / Container Scan (push) Successful in 12m24s
docker-build / docker (push) Successful in 1m59s
2025-06-21 23:00:48 +00:00
5fbd7ac091 fix(deps): update dependency @tanstack/react-query to v5.81.1
All checks were successful
container-scan / Container Scan (push) Successful in 11m46s
docker-build / docker (push) Successful in 2m9s
2025-06-21 20:01:54 +00:00
5e6feb39eb fix(deps): update dependency @tanstack/react-query to v5.81.0
All checks were successful
container-scan / Container Scan (push) Successful in 10m45s
docker-build / docker (push) Successful in 1m50s
2025-06-21 13:00:48 +00:00
b652499788 fix(deps): update dependency next to v15.4.0-canary.90
All checks were successful
container-scan / Container Scan (push) Successful in 12m6s
docker-build / docker (push) Successful in 1m48s
2025-06-21 02:01:51 +00:00
96ff00f120 chore(deps): update dependency prisma to v6.10.1
All checks were successful
container-scan / Container Scan (push) Successful in 11m1s
docker-build / docker (push) Successful in 12m29s
2025-06-21 00:01:44 +00:00
58cf178968 fix(deps): update dependency next to v15.4.0-canary.89
All checks were successful
container-scan / Container Scan (push) Successful in 9m57s
docker-build / docker (push) Successful in 12m5s
2025-06-20 23:01:56 +00:00
360f0788dd Merge pull request 'feat: core api endpoints' (#95)
All checks were successful
container-scan / Container Scan (push) Successful in 4m0s
docker-build / docker (push) Successful in 11m56s
Reviewed-on: #95
Reviewed-by: micha <MichaBokelmann@web.de>
2025-06-20 22:13:01 +00:00
445a15ccc7
feat(api): add username to homepage
All checks were successful
container-scan / Container Scan (pull_request) Successful in 8m8s
docker-build / docker (pull_request) Successful in 8m33s
example for the api usage
2025-06-20 14:00:13 +02:00
40d13101a3
feat(api): implement /api/event/[eventID]/participant/[user] endpoint 2025-06-20 14:00:12 +02:00
68cafccec7
feat(api): implement /api/event/[eventID]/participant endpoint 2025-06-20 14:00:11 +02:00
f5a5704be3
feat(api): implement /api/event/[eventID] endpoint 2025-06-20 14:00:10 +02:00
50d915854f
feat(api): implement /api/event endpoint 2025-06-20 14:00:09 +02:00
b10b374b84
feat(api): implement /api/search/user endpoint 2025-06-20 14:00:08 +02:00
c71de4a14c
feat(api): implement /api/user/[user]/calendar endpoint 2025-06-20 14:00:07 +02:00
eb04c276ab
feat(api): implement /api/user/[user] endpoint 2025-06-20 13:59:45 +02:00
3e890d4363
feat(api): implement /api/user/me endpoint 2025-06-20 13:59:44 +02:00
87dc6162f4
feat(api): upgrade zod to v4 and implement api docs and client generation 2025-06-20 13:59:43 +02:00
98776aacb2 Merge pull request 'refactor: organized component folder structure' (#98)
All checks were successful
container-scan / Container Scan (push) Successful in 5m13s
docker-build / docker (push) Successful in 12m22s
Reviewed-on: #98
Reviewed-by: Dominik <mail@dominikstahl.dev>
2025-06-18 21:16:50 +00:00
a412d0710b refactor: organized component folder structure
All checks were successful
container-scan / Container Scan (pull_request) Successful in 4m39s
docker-build / docker (pull_request) Successful in 10m19s
fix: scrolling in login page
2025-06-18 22:25:27 +02:00
050a1d2bf5 fix(deps): update nextjs monorepo to v15.3.4
All checks were successful
container-scan / Container Scan (push) Successful in 10m24s
docker-build / docker (push) Successful in 2m3s
2025-06-18 18:01:50 +00:00
138970f4c3 chore(deps): update docker/setup-buildx-action digest to e468171
Some checks failed
container-scan / Container Scan (push) Failing after 7m27s
docker-build / docker (push) Successful in 2m21s
2025-06-18 09:03:11 +00:00
77653bcc69 fix(deps): update dependency react-hook-form to v7.58.1
All checks were successful
container-scan / Container Scan (push) Successful in 7m18s
docker-build / docker (push) Successful in 1m49s
2025-06-17 14:01:11 +00:00
c49c654f9f fix(deps): update dependency zod to v3.25.67
All checks were successful
container-scan / Container Scan (push) Successful in 8m16s
docker-build / docker (push) Successful in 4m21s
2025-06-16 23:01:28 +00:00
34a2956399 Merge pull request 'chore(deps): update docker/setup-buildx-action digest to 18ce135' (#97)
All checks were successful
container-scan / Container Scan (push) Successful in 6m41s
docker-build / docker (push) Successful in 2m31s
Reviewed-on: #97
Reviewed-by: Dominik <mail@dominikstahl.dev>
2025-06-16 21:10:13 +00:00
d054fe1079 chore(deps): update docker/setup-buildx-action digest to 18ce135
All checks were successful
docker-build / docker (push) Successful in 4m26s
container-scan / Container Scan (pull_request) Successful in 3m11s
docker-build / docker (pull_request) Successful in 1m39s
2025-06-16 20:01:09 +00:00
8d3aa9ec85 fix(deps): update dependency zod to v3.25.65
All checks were successful
container-scan / Container Scan (push) Successful in 6m19s
docker-build / docker (push) Successful in 1m35s
2025-06-16 19:00:47 +00:00
882464e6cb chore(deps): update dependency postcss to v8.5.6
All checks were successful
container-scan / Container Scan (push) Successful in 5m29s
docker-build / docker (push) Successful in 1m43s
2025-06-16 14:00:55 +00:00
d769cdcd5c chore(deps): update dependency @types/node to v22.15.32
All checks were successful
container-scan / Container Scan (push) Successful in 4m32s
docker-build / docker (push) Successful in 1m22s
2025-06-16 09:00:52 +00:00
cfe73652e2 fix(deps): update dependency react-hook-form to v7.58.0
All checks were successful
container-scan / Container Scan (push) Successful in 7m38s
docker-build / docker (push) Successful in 1m38s
2025-06-15 12:01:00 +00:00
b7d443e7a1 fix(deps): update dependency zod to v3.25.64
All checks were successful
container-scan / Container Scan (push) Successful in 7m42s
docker-build / docker (push) Successful in 1m49s
2025-06-14 11:02:02 +00:00
378b88dbdb fix(deps): update dependency @hookform/resolvers to v5.1.1
All checks were successful
container-scan / Container Scan (push) Successful in 7m31s
docker-build / docker (push) Successful in 9m39s
2025-06-14 10:01:22 +00:00
c2861047d0 Merge pull request 'feat: implement credentials login' (#90)
All checks were successful
container-scan / Container Scan (push) Successful in 8m17s
docker-build / docker (push) Successful in 10m0s
Reviewed-on: #90
Reviewed-by: Maximilian Liebmann <lima@noreply.git.dominikstahl.dev>
2025-06-14 09:57:28 +00:00
4e87c11ec3 feat: implement credentials login
implements the credentials login functionality
2025-06-14 09:57:28 +00:00
210bd132cc chore(deps): update dependency eslint to v9.29.0
All checks were successful
container-scan / Container Scan (push) Successful in 7m21s
docker-build / docker (push) Successful in 1m58s
2025-06-13 18:00:50 +00:00
0e0c89fdd7 chore(deps): update ghcr.io/di0ik/forgejo_runner_container:main docker digest to c4667f2
All checks were successful
container-scan / Container Scan (push) Successful in 3m59s
docker-build / docker (push) Successful in 1m35s
2025-06-12 12:00:27 +00:00
de9216807c chore(deps): update tailwindcss monorepo to v4.1.10
All checks were successful
container-scan / Container Scan (push) Successful in 7m18s
docker-build / docker (push) Successful in 1m40s
2025-06-11 21:00:47 +00:00
dfbc092a7b chore(deps): update dependency postcss to v8.5.5
All checks were successful
container-scan / Container Scan (push) Successful in 6m28s
docker-build / docker (push) Successful in 1m49s
2025-06-11 18:00:44 +00:00
3f99449f2f chore(deps): update tailwindcss monorepo to v4.1.9
All checks were successful
container-scan / Container Scan (push) Successful in 6m46s
docker-build / docker (push) Successful in 1m50s
2025-06-11 15:01:22 +00:00
de2e1c22ff chore(deps): update dependency @types/react to v19.1.8
All checks were successful
container-scan / Container Scan (push) Successful in 6m15s
docker-build / docker (push) Successful in 2m6s
2025-06-11 14:00:48 +00:00
72a5c25838 Merge pull request 'fix: add cursor pointer to button variants for improved interactivity' (#89)
All checks were successful
container-scan / Container Scan (push) Successful in 3m23s
docker-build / docker (push) Successful in 4m33s
Reviewed-on: #89
Reviewed-by: Dominik <mail@dominikstahl.dev>
2025-06-11 06:13:56 +00:00
171f0ae099 fix: simplify button styling by removing unnecessary hover classes 2025-06-11 06:13:56 +00:00
a351a9017d fix: add cursor pointer to button variants for improved interactivity 2025-06-11 06:13:56 +00:00
15fbf27459 Merge pull request 'feat: register page' (#91)
Some checks failed
docker-build / docker (push) Waiting to run
container-scan / Container Scan (push) Has been cancelled
Reviewed-on: #91
Reviewed-by: Dominik <mail@dominikstahl.dev>
2025-06-11 06:13:23 +00:00
9183117a20 feat: add form reset functionality and ref to login form 2025-06-11 06:13:23 +00:00
386d72d914 fix: correct typo in Prisma command in README.md 2025-06-11 06:13:23 +00:00
6c479e80d6 feat: enhance login form with sign-up input-fields and autocomplete attributes 2025-06-11 06:13:23 +00:00
abae5c74d5 fix(deps): update dependency tailwind-merge to v3.3.1
All checks were successful
container-scan / Container Scan (push) Successful in 5m35s
docker-build / docker (push) Successful in 1m50s
2025-06-10 21:00:48 +00:00
3569ccc18e chore(deps): update dependency @types/node to v22.15.31
All checks were successful
container-scan / Container Scan (push) Successful in 5m20s
docker-build / docker (push) Successful in 1m20s
2025-06-10 03:01:07 +00:00
d4de7876cc chore(deps): update dependency @types/react to v19.1.7
All checks were successful
container-scan / Container Scan (push) Successful in 4m52s
docker-build / docker (push) Successful in 1m40s
2025-06-09 21:01:08 +00:00
0c93778c5a chore(deps): update node.js to 41e4389
All checks were successful
container-scan / Container Scan (push) Successful in 2m9s
docker-build / docker (push) Successful in 1m14s
2025-06-09 09:00:24 +00:00
4b80c89050 Merge pull request 'chore: add development docker environment' (#77)
All checks were successful
container-scan / Container Scan (push) Successful in 3m33s
docker-build / docker (push) Successful in 5m37s
Reviewed-on: #77
Reviewed-by: Maximilian Liebmann <lima@noreply.git.dominikstahl.dev>
2025-06-09 08:12:45 +00:00
16cde64761 docs: add note about docker development environment 2025-06-09 08:12:45 +00:00
a2a5eee49e chore: add development docker environment
adds a docker environment for development.
can be started using:
`docker compose -f docker-compose.dev.yml up --watch --build`
2025-06-09 08:12:45 +00:00
cdc0e81e51 Merge pull request 'fix(deps): update prisma monorepo to v6.9.0' (#86)
All checks were successful
container-scan / Container Scan (push) Successful in 3m22s
docker-build / docker (push) Successful in 1m47s
Reviewed-on: #86
Reviewed-by: Dominik <mail@dominikstahl.dev>
2025-06-07 13:31:59 +00:00
c732b3138e
fix(deps): update prisma monorepo to v6.9.0
All checks were successful
docker-build / docker (push) Successful in 11m37s
container-scan / Container Scan (pull_request) Successful in 9m5s
docker-build / docker (pull_request) Successful in 2m4s
2025-06-07 15:07:14 +02:00
24110a733d chore(deps): update dependency @types/node to v22.15.30
All checks were successful
container-scan / Container Scan (push) Successful in 7m22s
docker-build / docker (push) Successful in 9m25s
2025-06-05 19:00:43 +00:00
6cf0b59a9e Merge pull request 'refactor: create styling structure and restyle components to reflect brand design' (#85)
All checks were successful
container-scan / Container Scan (push) Successful in 2m53s
docker-build / docker (push) Successful in 9m33s
Reviewed-on: #85
Reviewed-by: Dominik <mail@dominikstahl.dev>
2025-06-05 18:03:49 +00:00
a17cf65452 feat: update button variants to 'outline_muted' for improved visual consistency
All checks were successful
container-scan / Container Scan (pull_request) Successful in 3m2s
docker-build / docker (pull_request) Successful in 7m39s
2025-06-05 19:50:20 +02:00
f0eb05c50d fix: update separator color and improve conditional rendering for provider map
Some checks failed
container-scan / Container Scan (pull_request) Failing after 1m0s
docker-build / docker (pull_request) Failing after 6m25s
2025-06-05 18:17:38 +02:00
7cefd39652 feat: add button font and enhance muted color variables for improved accessibility 2025-06-05 18:17:38 +02:00
327b984974 feat: update button variants and styles for improved consistency and accessibility 2025-06-05 18:17:38 +02:00
573c3053b6 refactor(ui): enhance component className structure for better readability and maintainability 2025-06-05 18:17:38 +02:00
9cb378f197 refactor: change className of input component 2025-06-05 18:14:39 +02:00
01c77101f9 Merge pull request 'refactor: style login page' (#80)
All checks were successful
container-scan / Container Scan (push) Successful in 2m58s
docker-build / docker (push) Successful in 9m19s
Reviewed-on: #80
Reviewed-by: Dominik <mail@dominikstahl.dev>
2025-06-05 15:54:27 +00:00
49b3869d7b feat(logo): consolidate logo imports into a single export file
All checks were successful
container-scan / Container Scan (pull_request) Successful in 3m10s
docker-build / docker (pull_request) Successful in 7m41s
2025-06-05 17:38:47 +02:00
f0e23139a2 refactor: remove debug log and unused onError prop 2025-06-05 17:38:47 +02:00
d4d4421f36 feat: add neutral-450 color variable and update muted input color for better contrast 2025-06-05 17:38:47 +02:00
b309688372 feat(routing): exclude fonts from redirect 2025-06-05 17:38:47 +02:00
9acb71d050 feat: add values to globals.css edited dropdown, input, label and select
to reflect new values
2025-06-05 17:38:47 +02:00
772c8f84e8 feat: finished creating login page
Fixed Color Picker
2025-06-05 17:38:47 +02:00
6350208c4d style(ui): update input and dropdown-menu colors for consistency
Refine color variables and class names in input and dropdown-menu
components to improve visual consistency and accessibility. Introduce
new CSS variables for input text and muted input states. Adjust focus
and selection styles to better align with the overall design system.
2025-06-05 17:38:47 +02:00
4b3e0677e4 feat(settings): add font and apply to relevant text fields 2025-06-05 17:34:40 +02:00
a1a482abcc feat(fonts): Add Varela Round and Comfortaa fonts and their OFL licenses
The changes add the SIL Open Font License (OFL) text files for the Varela Round and Comfortaa font families. This ensures the proper licensing information is included with the font files, allowing users to understand the terms under which they can use and distribute these fonts.
2025-06-05 17:22:45 +02:00
4f4e73318a feat(ui): Improve login card design and theme customization
Refactor the `Button` component, add new `LoginCard` component, update `ThemePicker` component, edit global CSS styles to include card background and foreground and style the login page, including the logo and login form.
2025-06-05 17:22:45 +02:00
9f9c2157f5 feat: refactor logo component to use dynamic imports for assets and remove theme prop 2025-06-05 17:22:45 +02:00
b5d1086131 feat(login): enhance login page UI with logo and separator
refactor: restyle login page according to figma
2025-06-05 17:22:45 +02:00
f3b5f5c87c feat: add separator component 2025-06-05 17:22:45 +02:00
198520ff83 refactor: restyle login form acording to figma 2025-06-05 17:22:45 +02:00
89cc743fda feat: add logo component
refactor: restyle input according to figma
2025-06-05 17:22:45 +02:00
af0570c2d7 refactor: restyle button according to figma and change login button variant to primary 2025-06-05 17:22:45 +02:00
effa57a501 chore(deps): update dependency @types/react-dom to v19.1.6
All checks were successful
container-scan / Container Scan (push) Successful in 5m54s
docker-build / docker (push) Successful in 8m14s
2025-06-04 13:01:07 +00:00
e897c55c68 chore(deps): update dependency tw-animate-css to v1.3.4
All checks were successful
container-scan / Container Scan (push) Successful in 5m50s
docker-build / docker (push) Successful in 7m31s
2025-06-04 10:00:44 +00:00
983951beed chore(deps): update yarn to v4.9.2
All checks were successful
container-scan / Container Scan (push) Successful in 2m21s
docker-build / docker (push) Successful in 56s
2025-06-03 21:00:55 +00:00
195332c46f chore(deps): update dependency tw-animate-css to v1.3.3
All checks were successful
container-scan / Container Scan (push) Successful in 3m35s
docker-build / docker (push) Successful in 1m1s
2025-06-01 10:01:11 +00:00
88faae0fc9 chore(deps): update node.js to 41e4389
All checks were successful
container-scan / Container Scan (push) Successful in 1m45s
docker-build / docker (push) Successful in 1m11s
2025-05-31 07:00:22 +00:00
6c1c4acd6e chore(deps): update node.js to fa5f577
All checks were successful
container-scan / Container Scan (push) Successful in 1m37s
docker-build / docker (push) Successful in 1m17s
2025-05-31 04:00:21 +00:00
5e5b58d33c chore(deps): update node.js to d0baf82
All checks were successful
container-scan / Container Scan (push) Successful in 3m15s
docker-build / docker (push) Successful in 1m19s
2025-05-31 01:00:25 +00:00
de596ec022 chore(deps): update dependency eslint to v9.28.0
All checks were successful
container-scan / Container Scan (push) Successful in 2m41s
docker-build / docker (push) Successful in 1m15s
2025-05-30 21:01:06 +00:00
974de82d5d chore(deps): update dependency @types/node to v22.15.29
All checks were successful
container-scan / Container Scan (push) Successful in 2m56s
docker-build / docker (push) Successful in 1m9s
2025-05-30 19:01:13 +00:00
287a082bf2 chore(deps): update dependency @types/node to v22.15.28
All checks were successful
container-scan / Container Scan (push) Successful in 2m57s
docker-build / docker (push) Successful in 1m11s
2025-05-30 16:01:02 +00:00
4decff19c3 chore(deps): update dependency @types/node to v22.15.27
All checks were successful
container-scan / Container Scan (push) Successful in 2m43s
docker-build / docker (push) Successful in 1m13s
2025-05-30 06:00:50 +00:00
c32b198340 chore(deps): update dependency @types/node to v22.15.26
All checks were successful
container-scan / Container Scan (push) Successful in 2m49s
docker-build / docker (push) Successful in 1m3s
2025-05-30 00:01:23 +00:00
ca3ea0e8f2 fix(deps): update nextjs monorepo to v15.3.3
All checks were successful
container-scan / Container Scan (push) Successful in 2m45s
docker-build / docker (push) Successful in 1m22s
2025-05-29 20:00:58 +00:00
7ad191467e Merge pull request 'fix(deps): update dependency lucide-react to ^0.511.0' (#64)
All checks were successful
container-scan / Container Scan (push) Successful in 2m57s
docker-build / docker (push) Successful in 4m35s
Reviewed-on: #64
Reviewed-by: Maximilian Liebmann <lima@noreply.git.dominikstahl.dev>
2025-05-29 19:57:44 +00:00
ea0030f56c fix(deps): update dependency lucide-react to ^0.511.0 2025-05-29 19:57:44 +00:00
6534801ad2 Merge pull request 'chore(deps): update docker/build-push-action digest to 2634353' (#82)
Some checks failed
docker-build / docker (push) Waiting to run
container-scan / Container Scan (push) Has been cancelled
Reviewed-on: #82
Reviewed-by: Dominik <mail@dominikstahl.dev>
2025-05-29 19:57:32 +00:00
e276f3b511 chore(deps): update docker/build-push-action digest to 2634353 2025-05-29 19:57:32 +00:00
5c829b226b Merge pull request 'chore(deps): update dependency @types/node to v22.15.24' (#83)
Some checks failed
docker-build / docker (push) Waiting to run
container-scan / Container Scan (push) Has been cancelled
Reviewed-on: #83
Reviewed-by: Dominik <mail@dominikstahl.dev>
2025-05-29 19:56:54 +00:00
1ac8d7ea8f chore(deps): update dependency @types/node to v22.15.24
All checks were successful
docker-build / docker (push) Successful in 3m53s
container-scan / Container Scan (pull_request) Successful in 2m38s
docker-build / docker (pull_request) Successful in 1m7s
2025-05-29 13:01:23 +00:00
efa56e2d3c chore(deps): update dependency tw-animate-css to v1.3.2
All checks were successful
container-scan / Container Scan (push) Successful in 2m42s
docker-build / docker (push) Successful in 1m10s
2025-05-29 12:00:55 +00:00
bc11b56b85 chore(deps): update dependency tw-animate-css to v1.3.1
All checks were successful
container-scan / Container Scan (push) Successful in 2m33s
docker-build / docker (push) Successful in 59s
2025-05-29 09:00:55 +00:00
821d529193 chore(deps): update dependency postcss to v8.5.4
Some checks failed
container-scan / Container Scan (push) Failing after 2m0s
docker-build / docker (push) Successful in 55s
2025-05-29 06:00:48 +00:00
0a2d3515c9 chore(deps): update tailwindcss monorepo to v4.1.8
All checks were successful
container-scan / Container Scan (push) Successful in 3m53s
docker-build / docker (push) Successful in 1m8s
2025-05-28 16:01:34 +00:00
3cf3663469 Merge pull request 'docs: update README with Prisma setup instructions' (#75)
All checks were successful
container-scan / Container Scan (push) Successful in 5m8s
docker-build / docker (push) Successful in 4m48s
Reviewed-on: #75
Reviewed-by: Dominik <mail@dominikstahl.dev>
2025-05-28 11:12:27 +00:00
b28e4e40c9 chore(deps): update dependency @types/node to v22.15.23
All checks were successful
container-scan / Container Scan (push) Successful in 3m53s
docker-build / docker (push) Successful in 1m21s
2025-05-27 19:00:55 +00:00
9630e590fe chore(deps): update dependency @types/react to v19.1.6
All checks were successful
container-scan / Container Scan (push) Successful in 4m24s
docker-build / docker (push) Successful in 1m10s
2025-05-27 09:00:57 +00:00
226c257b6f Merge pull request 'feat: add favicon.ico' (#79)
All checks were successful
container-scan / Container Scan (push) Successful in 2m28s
docker-build / docker (push) Successful in 4m41s
Reviewed-on: #79
Reviewed-by: Dominik <mail@dominikstahl.dev>
2025-05-22 14:05:33 +00:00
e298aec60e style: yarn format fix komma issue
All checks were successful
container-scan / Container Scan (pull_request) Successful in 2m37s
docker-build / docker (pull_request) Successful in 4m2s
2025-05-22 15:53:16 +02:00
df7413d6ba fix: correct regex pattern for matcher in middleware
Some checks failed
container-scan / Container Scan (pull_request) Failing after 18s
docker-build / docker (pull_request) Failing after 3m19s
2025-05-22 15:45:12 +02:00
89b73865ec fix: add newline at end of middleware.ts file
Some checks failed
container-scan / Container Scan (pull_request) Failing after 34s
docker-build / docker (pull_request) Failing after 3m22s
2025-05-22 15:27:46 +02:00
76f75f0b57 fix: update matcher regex for web app manifest and favicon
Some checks failed
container-scan / Container Scan (pull_request) Failing after 31s
docker-build / docker (pull_request) Failing after 3m11s
2025-05-22 15:25:46 +02:00
5bcb16a9b5 refactor: simplify middleware
All checks were successful
container-scan / Container Scan (pull_request) Successful in 2m47s
docker-build / docker (pull_request) Successful in 4m33s
2025-05-22 15:12:23 +02:00
784549f7e1 fix: remove unused favicon image
All checks were successful
container-scan / Container Scan (pull_request) Successful in 2m58s
docker-build / docker (pull_request) Successful in 4m14s
2025-05-22 14:59:13 +02:00
08a83ba3c5 feat: add .png icons for safari
All checks were successful
container-scan / Container Scan (pull_request) Successful in 2m51s
docker-build / docker (pull_request) Successful in 4m26s
2025-05-22 14:38:30 +02:00
c2a074f734 fix: format previous commit
All checks were successful
container-scan / Container Scan (pull_request) Successful in 3m0s
docker-build / docker (pull_request) Successful in 4m34s
2025-05-22 13:19:21 +02:00
e5359ea4d3 feat: add favicon and web app manifest files 2025-05-22 13:18:33 +02:00
42ca2e1df9 chore(deps): update node.js to 9f3ae04
All checks were successful
container-scan / Container Scan (push) Successful in 2m54s
docker-build / docker (push) Successful in 1m51s
2025-05-22 01:00:22 +00:00
5315b6b81f chore(deps): update node.js to c296163
All checks were successful
container-scan / Container Scan (push) Successful in 7m52s
docker-build / docker (push) Successful in 1m41s
2025-05-21 22:00:23 +00:00
b2513609df docs: fix formatting in Prisma setup instructions
All checks were successful
docker-build / docker (pull_request) Successful in 9m50s
container-scan / Container Scan (pull_request) Successful in 3m25s
2025-05-21 14:29:52 +02:00
48d5b45e21 docs: update README with Prisma setup instructions
Some checks failed
container-scan / Container Scan (pull_request) Successful in 4m22s
docker-build / docker (pull_request) Failing after 4m43s
2025-05-21 14:16:11 +02:00
cdcad254cf chore(deps): update dependency @types/react to v19.1.5
All checks were successful
docker-build / docker (push) Successful in 5m42s
container-scan / Container Scan (push) Successful in 7m48s
2025-05-21 11:00:46 +00:00
96809db26f Merge pull request 'feat: update css colors to match figma design' (#73)
All checks were successful
container-scan / Container Scan (push) Successful in 6m45s
docker-build / docker (push) Successful in 4m46s
Reviewed-on: #73
Reviewed-by: Dominik <mail@dominikstahl.dev>
2025-05-21 08:06:17 +00:00
88dc6303c1 feat: add transparent color variable and update background color in globals.css
All checks were successful
container-scan / Container Scan (pull_request) Successful in 2m29s
docker-build / docker (pull_request) Successful in 4m16s
2025-05-21 07:43:30 +00:00
590b7c5696 feat: added figma css variables to globals.css 2025-05-21 07:43:30 +00:00
129 changed files with 15202 additions and 883 deletions

View file

@ -1,6 +1,6 @@
AUTH_SECRET="auth_secret"
AUTH_URL="http://127.0.0.1:3000"
HOSTNAME="127.0.0.1"
DATABASE_URL="file:./dev.db"
DATABASE_URL="file:/tmp/dev.db"
AUTH_AUTHENTIK_ID="id"
AUTH_AUTHENTIK_ISSUER="issuer"

View file

@ -9,7 +9,7 @@ jobs:
name: Container Scan
runs-on: docker
container:
image: ghcr.io/di0ik/forgejo_runner_container:main@sha256:c66a37d9af18f8f0f34d16890082bc08d842d52ff2a2bc36d993e3d347b498ac
image: ghcr.io/di0ik/forgejo_runner_container:main@sha256:c4667f2702c32b91b4c92db2ff20739edd00409a44a691c0598cf4a09a47743a
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@ -32,7 +32,6 @@ jobs:
path: trivy-report.json
- name: Clean up Docker
if: always()
run: |
docker system prune -af
docker volume prune -f
docker buildx prune --filter=until=48h -f
docker image rm meetup_trivy

View file

@ -13,7 +13,7 @@ jobs:
docker:
runs-on: docker
container:
image: ghcr.io/di0ik/forgejo_runner_container:main@sha256:c66a37d9af18f8f0f34d16890082bc08d842d52ff2a2bc36d993e3d347b498ac
image: ghcr.io/di0ik/forgejo_runner_container:main@sha256:c4667f2702c32b91b4c92db2ff20739edd00409a44a691c0598cf4a09a47743a
steps:
- name: Login to Docker Hub
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
@ -26,7 +26,7 @@ jobs:
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
- name: Get the Ref
id: get-ref
@ -40,7 +40,7 @@ jobs:
echo "REPO=$(echo $GITHUB_REPOSITORY | tr '[:upper:]' '[:lower:]')" >>${GITHUB_ENV}
- name: Build and push (pull_request)
uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
if: github.event_name == 'pull_request'
with:
push: true
@ -48,7 +48,7 @@ jobs:
cache-from: type=registry,ref=git.dominikstahl.dev/${{ env.REPO }}:buildcache
- name: Build and push (push_tag)
uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
if: github.event_name == 'push' && github.ref_type == 'tag'
with:
push: true
@ -56,7 +56,7 @@ jobs:
cache-from: type=registry,ref=git.dominikstahl.dev/${{ env.REPO }}:buildcache
- name: Build and push (push_branch)
uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
if: github.event_name == 'push' && github.ref_type == 'branch'
with:
push: true
@ -65,7 +65,5 @@ jobs:
cache-to: type=registry,ref=git.dominikstahl.dev/${{ env.REPO }}:buildcache,mode=max
- name: Clean up Docker
if: always()
run: |
docker system prune -af
docker volume prune -f
docker buildx prune --filter=until=48h -f

2
.gitignore vendored
View file

@ -44,7 +44,7 @@ next-env.d.ts
# database
/prisma/*.db*
src/generated/prisma
src/generated/*
data
# cypress

View file

@ -4,6 +4,8 @@
"vivaxy.vscode-conventional-commits",
"dbaeumer.vscode-eslint",
"bradlc.vscode-tailwindcss",
"bourhaouta.tailwindshades"
"bourhaouta.tailwindshades",
"nize.oklch-preview",
"azizziy.oklch-as"
]
}

View file

@ -1,4 +1,4 @@
FROM node:22-alpine@sha256:152270cd4bd094d216a84cbc3c5eb1791afb05af00b811e2f0f04bdc6c473602 AS base
FROM node:22-alpine@sha256:41e4389f3d988d2ed55392df4db1420ad048ae53324a8e2b7c6d19508288107e AS base
# ----- Dependencies -----
FROM base AS deps
@ -16,6 +16,8 @@ RUN corepack enable
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN yarn prisma:generate
RUN yarn swagger:generate
RUN yarn orval:generate
RUN yarn build
# ----- Runner -----

17
Dockerfile.dev Normal file
View file

@ -0,0 +1,17 @@
FROM node:22-alpine@sha256:41e4389f3d988d2ed55392df4db1420ad048ae53324a8e2b7c6d19508288107e
WORKDIR /app
RUN corepack enable
COPY package.json yarn.lock .yarnrc.yml ./
RUN yarn install --frozen-lockfile
COPY . .
ENV NODE_ENV=development
ENV NEXT_TELEMETRY_DISABLED=1
EXPOSE 3000
ENV HOSTNAME="0.0.0.0"
CMD ["/bin/ash", "entrypoint.dev.sh"]

View file

@ -99,12 +99,20 @@ 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
yarn prisma:generate
```
```bash
yarn prisma:db:push
```
- Run the following command to apply migrations and generate Prisma Client:
```bash
npx prisma migrate dev
# You might be prompted to name your first migration.
```
- (Optional: If you need to generate Prisma Client without running migrations, use `npx prisma generate`)
Tipp: You can open the prisma database UI with `yarn prisma:studio`
5. **Run the development server:**
@ -121,6 +129,14 @@ This project is built with a modern tech stack:
password: password
```
**Docker Development Environment:**
- The docker development environment can be started with the following command:
```bash
yarn dev_container
```
**Self-Hosting with Docker (Planned):**
- A Docker image and `docker-compose.yml` file will be provided in the future to allow for easy self-hosting of the MeetUP application. This setup will also include database services. Instructions will be updated here once available.

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

View file

@ -1,30 +1,44 @@
describe('login', () => {
describe('login and register', () => {
it('loads', () => {
cy.visit('http://127.0.0.1:3000/');
cy.getBySel('login-header').should('exist');
});
it('shows login form', () => {
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');
});
it('shows sso button', () => {
cy.visit('http://127.0.0.1:3000/');
cy.getBySel('sso-login-button_authentik').should('exist');
});
it('allows login', () => {
cy.visit('http://127.0.0.1:3000/');
cy.getBySel('email-input').type('test@example.com');
cy.getBySel('password-input').type('password');
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

@ -1,5 +0,0 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

32
docker-compose.dev.yml Normal file
View file

@ -0,0 +1,32 @@
services:
app:
build:
context: .
dockerfile: Dockerfile.dev
ports:
- '3000:3000'
environment:
- AUTH_SECRET=secret
- AUTH_URL=http://localhost:3000
- DATABASE_URL=file:/data/db.sqlite
env_file:
- .env.local
volumes:
- ./data:/data
- ./src/generated:/app/src/generated
develop:
watch:
- action: sync
path: ./src
target: /app/src
ignore:
- node_modules/
- generated/
- action: rebuild
path: package.json
- action: sync+restart
path: prisma
target: /app/prisma
- action: sync+restart
path: ./src/app/api
target: /app/src/app/api

13
entrypoint.dev.sh Normal file
View file

@ -0,0 +1,13 @@
#!/bin/bash
echo "Running start script with user $(whoami) and NODE_ENV $NODE_ENV"
if [ -d "prisma" ]; then
echo "Syncing Prisma database"
yarn prisma:generate
yarn prisma:db:push
fi
yarn swagger:generate
yarn orval:generate
exec yarn dev

61
exportSwagger.ts Normal file
View file

@ -0,0 +1,61 @@
import { registry } from '@/lib/swagger';
import { OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi';
import fs from 'fs';
import path from 'path';
function recursiveFileSearch(dir: string, fileList: string[] = []): string[] {
const files = fs.readdirSync(dir);
files.forEach((file) => {
const filePath = path.join(dir, file);
if (fs.statSync(filePath).isDirectory()) {
recursiveFileSearch(filePath, fileList);
} else if (file.match(/swagger\.ts$/)) {
fileList.push(filePath);
}
});
return fileList;
}
async function exportSwagger() {
const filesToImport = recursiveFileSearch(
path.join(process.cwd(), 'src', 'app', 'api'),
);
await Promise.all(
filesToImport.map(async (file) => {
try {
const moduleImp = await import(file);
if (moduleImp.default) {
moduleImp.default(registry);
}
} catch (error) {
console.error(`Error importing ${file}:`, error);
}
}),
);
await import('./src/app/api/validation');
const generator = new OpenApiGeneratorV3(registry.definitions);
const spec = generator.generateDocument({
openapi: '3.0.0',
info: {
version: '1.0.0',
title: 'MeetUP',
description: 'API documentation for MeetUP application',
},
});
const outputPath = path.join(
process.cwd(),
'src',
'generated',
'swagger.json',
);
fs.writeFileSync(outputPath, JSON.stringify(spec, null, 2), 'utf8');
console.log(`Swagger JSON generated at ${outputPath}`);
}
exportSwagger().catch((error) => {
console.error('Error exporting Swagger:', error);
});

10
orval.config.js Normal file
View file

@ -0,0 +1,10 @@
module.exports = {
meetup: {
input: './src/generated/swagger.json',
output: {
mode: 'tags-split',
target: './src/generated/api/meetup.ts',
client: 'react-query',
},
},
};

View file

@ -8,60 +8,79 @@
"start": "node .next/standalone/server.js",
"lint": "next lint",
"format": "prettier --write .",
"cypress:build": "prettier --check . && NODE_ENV=test next build",
"cypress:start_server": "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",
"prisma:migrate": "dotenv -e .env.local -- prisma migrate dev",
"prisma:generate": "dotenv -e .env.local -- prisma generate",
"prisma:studio": "dotenv -e .env.local -- prisma studio",
"prisma:db:push": "dotenv -e .env.local -- prisma db push",
"prisma:migrate:reset": "dotenv -e .env.local -- prisma migrate reset"
"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",
"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",
"@auth/prisma-adapter": "^2.9.1",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@prisma/client": "^6.8.2",
"@radix-ui/react-dropdown-menu": "^2.1.14",
"@hookform/resolvers": "^5.0.1",
"@prisma/client": "^6.9.0",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-hover-card": "^1.1.13",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-scroll-area": "^1.2.8",
"@radix-ui/react-select": "^2.2.4",
"@radix-ui/react-separator": "^1.1.6",
"@radix-ui/react-slot": "^1.2.2",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.7",
"@tanstack/react-query": "^5.80.7",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.510.0",
"next": "15.3.2",
"lucide-react": "^0.515.0",
"next": "15.4.0-canary.95",
"next-auth": "^5.0.0-beta.25",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.2.0"
"react-hook-form": "^7.56.4",
"swagger-ui-react": "^5.24.1",
"tailwind-merge": "^3.2.0",
"zod": "^3.25.60"
},
"devDependencies": {
"@eslint/eslintrc": "3.3.1",
"@tailwindcss/postcss": "4.1.7",
"@types/node": "22.15.21",
"@types/react": "19.1.4",
"@types/react-dom": "19.1.5",
"cypress": "14.3.3",
"@tailwindcss/postcss": "4.1.10",
"@types/node": "22.15.33",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@types/swagger-ui-react": "5",
"@types/webpack-env": "1.18.8",
"cypress": "14.5.0",
"dotenv-cli": "8.0.0",
"eslint": "9.27.0",
"eslint-config-next": "15.3.2",
"eslint": "9.29.0",
"eslint-config-next": "15.3.4",
"eslint-config-prettier": "10.1.5",
"postcss": "8.5.3",
"orval": "7.10.0",
"postcss": "8.5.6",
"prettier": "3.5.3",
"prisma": "6.8.2",
"tailwindcss": "4.1.7",
"tw-animate-css": "1.3.0",
"prisma": "6.10.1",
"tailwindcss": "4.1.10",
"ts-node": "10.9.2",
"tsconfig-paths": "4.2.0",
"tw-animate-css": "1.3.4",
"typescript": "5.8.3"
},
"packageManager": "yarn@4.9.1"
"packageManager": "yarn@4.9.2"
}

View file

@ -158,8 +158,8 @@ model Friendship {
requested_at DateTime @default(now())
accepted_at DateTime?
user1 User @relation("FriendshipUser1", fields: [user_id_1], references: [id])
user2 User @relation("FriendshipUser2", fields: [user_id_2], references: [id])
user1 User @relation("FriendshipUser1", fields: [user_id_1], references: [id], onDelete: Cascade)
user2 User @relation("FriendshipUser2", fields: [user_id_2], references: [id], onDelete: Cascade)
@@id([user_id_1, user_id_2])
@@index([user_id_2, status], name: "idx_friendships_user2_status")
@ -187,8 +187,8 @@ model GroupMember {
role group_member_role @default(MEMBER)
added_at DateTime @default(now())
group Group @relation(fields: [group_id], references: [id])
user User @relation(fields: [user_id], references: [id])
group Group @relation(fields: [group_id], references: [id], onDelete: Cascade)
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
@@id([group_id, user_id])
@@index([user_id])
@ -207,7 +207,7 @@ model BlockedSlot {
created_at DateTime @default(now())
updated_at DateTime @updatedAt
user User @relation(fields: [user_id], references: [id])
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
@@index([user_id, start_time, end_time])
@@index([user_id, is_recurring])
@ -241,8 +241,8 @@ model MeetingParticipant {
status participant_status @default(PENDING)
added_at DateTime @default(now())
meeting Meeting @relation(fields: [meeting_id], references: [id])
user User @relation(fields: [user_id], references: [id])
meeting Meeting @relation(fields: [meeting_id], references: [id], onDelete: Cascade)
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
@@id([meeting_id, user_id])
@@index([user_id, status], name: "idx_participants_user_status")
@ -259,7 +259,7 @@ model Notification {
is_read Boolean @default(false)
created_at DateTime @default(now())
user User @relation(fields: [user_id], references: [id])
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
@@index([user_id, is_read, created_at], name: "idx_notifications_user_read_time")
@@map("notifications")
@ -271,7 +271,7 @@ model UserNotificationPreference {
email_enabled Boolean @default(false)
updated_at DateTime @default(now())
user User @relation(fields: [user_id], references: [id])
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
@@id([user_id, notification_type])
@@map("user_notification_preferences")
@ -292,7 +292,7 @@ model EmailQueue {
created_at DateTime @default(now())
updated_at DateTime @updatedAt
user User @relation(fields: [user_id], references: [id])
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
@@index([status, scheduled_at], name: "idx_email_queue_pending_jobs")
@@index([user_id, created_at], name: "idx_email_queue_user_history")
@ -308,7 +308,7 @@ model CalendarExportToken {
created_at DateTime @default(now())
last_accessed_at DateTime?
user User @relation(fields: [user_id], references: [id])
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
@@index([user_id])
@@map("calendar_export_tokens")
@ -327,7 +327,7 @@ model CalendarSubscription {
created_at DateTime @default(now())
updated_at DateTime @updatedAt
user User @relation(fields: [user_id], references: [id])
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
externalEvents ExternalEvent[]
@@index([user_id, is_enabled])
@ -350,7 +350,7 @@ model ExternalEvent {
show_as_free Boolean @default(false)
last_fetched_at DateTime @default(now())
subscription CalendarSubscription @relation(fields: [subscription_id], references: [id])
subscription CalendarSubscription @relation(fields: [subscription_id], references: [id], onDelete: Cascade)
@@unique([subscription_id, ical_uid], name: "uq_external_event_sub_uid")
@@index([subscription_id, start_time, end_time])

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
public/favicon-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

10
public/favicon-dark.svg Normal file
View file

@ -0,0 +1,10 @@
<svg width="128" height="144" viewBox="0 0 128 144" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="10.3334" y="56.6667" width="107.333" height="76.6667" fill="#C1830D"/>
<path d="M5.22227 22.8889C5.22227 21.7843 6.11771 20.8889 7.22227 20.8889H120.778C121.882 20.8889 122.778 21.7843 122.778 22.8889V46.4445H5.22227V22.8889Z" fill="#5770FF"/>
<rect x="3.11113" y="18.7778" width="121.778" height="121.778" rx="3" stroke="white" stroke-width="6"/>
<line x1="0.11113" y1="50.5556" x2="127.889" y2="50.5556" stroke="white" stroke-width="2"/>
<rect x="27.6667" y="2.44446" width="13.6402" height="28.7926" rx="4" fill="#C1830D" stroke="white" stroke-width="4"/>
<rect x="83.8889" y="2.44446" width="13.6402" height="28.7926" rx="4" fill="#C1830D" stroke="white" stroke-width="4"/>
<path d="M121.304 46.4444C123.158 46.4444 124.718 47.0473 125.987 48.2531C127.255 49.4589 127.889 50.9429 127.889 52.7052L127.889 137.156C127.889 138.918 127.255 140.402 125.987 141.608C124.718 142.906 123.158 143.556 121.304 143.556H62.6255C60.8696 143.556 59.4063 142.953 58.2356 141.747C56.9674 140.634 56.3333 139.243 56.3333 137.573C56.3333 135.904 56.9674 134.512 58.2356 133.399C59.4063 132.286 60.8696 131.73 62.6255 131.73H114.573V100.982H84.1741C82.4181 100.982 80.9548 100.38 79.7842 99.1738C78.516 98.0608 77.8819 96.6695 77.8819 95C77.8819 93.3305 78.516 91.9392 79.7842 90.8262C80.9548 89.7131 82.4181 89.1566 84.1741 89.1566H114.573V58.2703H62.6255C60.8696 58.2703 59.4063 57.6674 58.2356 56.4616C56.9674 55.3486 56.3333 53.9573 56.3333 52.2878C56.3333 50.6183 56.9674 49.227 58.2356 48.114C59.4063 47.0009 60.8696 46.4444 62.6255 46.4444H121.304Z" fill="white"/>
<path d="M6.696 143.556C4.84248 143.556 3.28162 142.953 2.01343 141.747C0.745229 140.541 0.11113 139.057 0.11113 137.295V52.8443C0.11113 51.082 0.745229 49.598 2.01343 48.3922C3.28162 47.0937 4.84248 46.4445 6.696 46.4445H65.3745C67.1304 46.4445 68.5938 47.0473 69.7644 48.2531C71.0326 49.3661 71.6667 50.7574 71.6667 52.4269C71.6667 54.0965 71.0326 55.4878 69.7644 56.6008C68.5938 57.7138 67.1304 58.2703 65.3745 58.2703H13.4272V89.0175H43.826C45.5819 89.0175 47.0452 89.6204 48.2159 90.8262C49.4841 91.9392 50.1182 93.3305 50.1182 95C50.1182 96.6695 49.4841 98.0608 48.2159 99.1738C47.0452 100.287 45.5819 100.843 43.826 100.843H13.4272V131.73H65.3745C67.1304 131.73 68.5938 132.333 69.7644 133.538C71.0326 134.651 71.6667 136.043 71.6667 137.712C71.6667 139.382 71.0326 140.773 69.7644 141.886C68.5938 142.999 67.1304 143.556 65.3745 143.556H6.696Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
public/favicon-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

10
public/favicon-light.svg Normal file
View file

@ -0,0 +1,10 @@
<svg width="128" height="144" viewBox="0 0 128 144" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="10.3334" y="56.6667" width="107.333" height="76.6667" fill="#E69D11"/>
<path d="M5.22227 22.8889C5.22227 21.7843 6.11771 20.8889 7.22227 20.8889H120.778C121.882 20.8889 122.778 21.7843 122.778 22.8889V46.4445H5.22227V22.8889Z" fill="#4154C0"/>
<rect x="3.11113" y="18.7778" width="121.778" height="121.778" rx="3" stroke="black" stroke-width="6"/>
<line x1="0.11113" y1="50.5556" x2="127.889" y2="50.5556" stroke="black" stroke-width="2"/>
<rect x="27.6667" y="2.44446" width="13.6402" height="28.7926" rx="4" fill="#E69D11" stroke="black" stroke-width="4"/>
<rect x="83.8889" y="2.44446" width="13.6402" height="28.7926" rx="4" fill="#E69D11" stroke="black" stroke-width="4"/>
<path d="M121.304 46.4444C123.158 46.4444 124.718 47.0473 125.987 48.2531C127.255 49.4589 127.889 50.9429 127.889 52.7052L127.889 137.156C127.889 138.918 127.255 140.402 125.987 141.608C124.718 142.906 123.158 143.556 121.304 143.556H62.6255C60.8696 143.556 59.4063 142.953 58.2356 141.747C56.9674 140.634 56.3333 139.243 56.3333 137.573C56.3333 135.904 56.9674 134.512 58.2356 133.399C59.4063 132.286 60.8696 131.73 62.6255 131.73H114.573V100.982H84.1741C82.4181 100.982 80.9548 100.38 79.7842 99.1738C78.516 98.0608 77.8819 96.6695 77.8819 95C77.8819 93.3305 78.516 91.9392 79.7842 90.8262C80.9548 89.7131 82.4181 89.1566 84.1741 89.1566H114.573V58.2703H62.6255C60.8696 58.2703 59.4063 57.6674 58.2356 56.4616C56.9674 55.3486 56.3333 53.9573 56.3333 52.2878C56.3333 50.6183 56.9674 49.227 58.2356 48.114C59.4063 47.0009 60.8696 46.4444 62.6255 46.4444H121.304Z" fill="black"/>
<path d="M6.696 143.556C4.84248 143.556 3.28162 142.953 2.01343 141.747C0.745229 140.541 0.11113 139.057 0.11113 137.295V52.8443C0.11113 51.082 0.745229 49.598 2.01343 48.3922C3.28162 47.0937 4.84248 46.4445 6.696 46.4445H65.3745C67.1304 46.4445 68.5938 47.0473 69.7644 48.2531C71.0326 49.3661 71.6667 50.7574 71.6667 52.4269C71.6667 54.0965 71.0326 55.4878 69.7644 56.6008C68.5938 57.7138 67.1304 58.2703 65.3745 58.2703H13.4272V89.0175H43.826C45.5819 89.0175 47.0452 89.6204 48.2159 90.8262C49.4841 91.9392 50.1182 93.3305 50.1182 95C50.1182 96.6695 49.4841 98.0608 48.2159 99.1738C47.0452 100.287 45.5819 100.843 43.826 100.843H13.4272V131.73H65.3745C67.1304 131.73 68.5938 132.333 69.7644 133.538C71.0326 134.651 71.6667 136.043 71.6667 137.712C71.6667 139.382 71.0326 140.773 69.7644 141.886C68.5938 142.999 67.1304 143.556 65.3745 143.556H6.696Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,93 @@
Copyright 2011 The Comfortaa Project Authors (https://github.com/alexeiva/comfortaa), with Reserved Font Name "Comfortaa".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -0,0 +1,67 @@
Comfortaa Variable Font
=======================
This download contains Comfortaa as both a variable font and static fonts.
Comfortaa is a variable font with this axis:
wght
This means all the styles are contained in a single file:
Comfortaa-VariableFont_wght.ttf
If your app fully supports variable fonts, you can now pick intermediate styles
that arent available as static fonts. Not all apps support variable fonts, and
in those cases you can use the static font files for Comfortaa:
static/Comfortaa-Light.ttf
static/Comfortaa-Regular.ttf
static/Comfortaa-Medium.ttf
static/Comfortaa-SemiBold.ttf
static/Comfortaa-Bold.ttf
Get started
-----------
1. Install the font files you want to use
2. Use your app's font picker to view the font family and all the
available styles
Learn more about variable fonts
-------------------------------
https://developers.google.com/web/fundamentals/design-and-ux/typography/variable-fonts
https://variablefonts.typenetwork.com
https://medium.com/variable-fonts
In desktop apps
https://theblog.adobe.com/can-variable-fonts-illustrator-cc
https://helpx.adobe.com/nz/photoshop/using/fonts.html#variable_fonts
Online
https://developers.google.com/fonts/docs/getting_started
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide
https://developer.microsoft.com/en-us/microsoft-edge/testdrive/demos/variable-fonts
Installing fonts
MacOS: https://support.apple.com/en-us/HT201749
Linux: https://www.google.com/search?q=how+to+install+a+font+on+gnu%2Blinux
Windows: https://support.microsoft.com/en-us/help/314960/how-to-install-or-remove-a-font-in-windows
Android Apps
https://developers.google.com/fonts/docs/android
https://developer.android.com/guide/topics/ui/look-and-feel/downloadable-fonts
License
-------
Please read the full license text (OFL.txt) to understand the permissions,
restrictions and requirements for usage, redistribution, and modification.
You can use them in your products & projects print or digital,
commercial or otherwise.
This isn't legal advice, please consider consulting a lawyer and see the full
license for all details.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,93 @@
Copyright 2023 The Varela Round Project Authors (https://github.com/alefalefalef/Varela-Round-Hebrew/), with Reserved Font Names 'Varela' and Varela Round.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

21
public/site.webmanifest Normal file
View file

@ -0,0 +1,21 @@
{
"name": "MyWebSite",
"short_name": "MySite",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,21 @@
'use client';
import { RedirectButton } from '@/components/buttons/redirect-button';
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-full'>
<div>
<h1>
Hello{' '}
{isLoading ? 'Loading...' : data?.data.user?.name || 'Unknown User'}
</h1>
<RedirectButton redirectUrl='/logout' buttonText='Logout' />
<RedirectButton redirectUrl='/settings' buttonText='Settings' />
</div>
</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>
</>
);
}

11
src/app/api-doc/page.tsx Normal file
View file

@ -0,0 +1,11 @@
import { getApiDocs } from '@/lib/swagger';
import ReactSwagger from './react-swagger';
export default async function IndexPage() {
const spec = await getApiDocs();
return (
<section className='container'>
<ReactSwagger spec={spec} />
</section>
);
}

View file

@ -0,0 +1,14 @@
'use client';
import SwaggerUI from 'swagger-ui-react';
import 'swagger-ui-react/swagger-ui.css';
type Props = {
spec: object;
};
function ReactSwagger({ spec }: Props) {
return <SwaggerUI spec={spec} />;
}
export default ReactSwagger;

View file

@ -0,0 +1,276 @@
import { prisma } from '@/prisma';
import { auth } from '@/auth';
import {
returnZodTypeCheckedResponse,
userAuthenticated,
} from '@/lib/apiHelpers';
import {
ErrorResponseSchema,
SuccessResponseSchema,
ZodErrorResponseSchema,
} from '@/app/api/validation';
import {
ParticipantResponseSchema,
updateParticipantSchema,
} from '../validation';
export const GET = auth(async (req, { params }) => {
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 },
);
const eventID = (await params).eventID;
const user = (await params).user;
const isParticipant = await prisma.meetingParticipant.findFirst({
where: {
meeting_id: eventID,
user_id: dbUser.id,
},
});
const isOrganizer = await prisma.meeting.findFirst({
where: {
id: eventID,
organizer_id: dbUser.id,
},
});
if (!isParticipant && !isOrganizer)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{
success: false,
message: 'User is not a participant or organizer of this event',
},
{ status: 403 },
);
const participant = await prisma.meetingParticipant.findUnique({
where: {
meeting_id_user_id: {
meeting_id: eventID,
user_id: user,
},
},
select: {
user: {
select: {
id: true,
name: true,
first_name: true,
last_name: true,
image: true,
timezone: true,
},
},
status: true,
},
});
if (!participant)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{ success: false, message: 'Participant not found' },
{ status: 404 },
);
return returnZodTypeCheckedResponse(ParticipantResponseSchema, {
success: true,
participant,
});
});
export const DELETE = auth(async (req, { params }) => {
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 },
);
const eventID = (await params).eventID;
const user = (await params).user;
const isOrganizer = await prisma.meeting.findFirst({
where: {
id: eventID,
organizer_id: dbUser.id,
},
});
if (!isOrganizer)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{ success: false, message: 'Only organizer can remove participants' },
{ status: 403 },
);
const participant = await prisma.meetingParticipant.findUnique({
where: {
meeting_id_user_id: {
meeting_id: eventID,
user_id: user,
},
},
});
if (!participant)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{ success: false, message: 'Participant not found' },
{ status: 404 },
);
await prisma.meetingParticipant.delete({
where: {
meeting_id_user_id: {
meeting_id: eventID,
user_id: user,
},
},
});
return returnZodTypeCheckedResponse(
SuccessResponseSchema,
{ success: true, message: 'Participant removed successfully' },
{ status: 200 },
);
});
export const PATCH = auth(async (req, { params }) => {
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 },
);
const eventID = (await params).eventID;
const user = (await params).user;
if (dbUser.id !== user && dbUser.name !== user)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{ success: false, message: 'You can only update your own participation' },
{ status: 403 },
);
const participant = await prisma.meetingParticipant.findUnique({
where: {
meeting_id_user_id: {
meeting_id: eventID,
user_id: dbUser.id,
},
},
select: {
user: {
select: {
id: true,
name: true,
first_name: true,
last_name: true,
image: true,
timezone: true,
},
},
status: true,
},
});
if (!participant)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{ success: false, message: 'Participant not found' },
{ status: 404 },
);
const body = await req.json();
const parsedBody = await updateParticipantSchema.safeParseAsync(body);
if (!parsedBody.success)
return returnZodTypeCheckedResponse(
ZodErrorResponseSchema,
{
success: false,
message: 'Invalid request body',
errors: parsedBody.error.issues,
},
{ status: 400 },
);
const { status } = parsedBody.data;
const updatedParticipant = await prisma.meetingParticipant.update({
where: {
meeting_id_user_id: {
meeting_id: eventID,
user_id: dbUser.id,
},
},
data: {
status,
},
select: {
user: {
select: {
id: true,
name: true,
first_name: true,
last_name: true,
image: true,
timezone: true,
},
},
status: true,
},
});
return returnZodTypeCheckedResponse(ParticipantResponseSchema, {
success: true,
participant: updatedParticipant,
});
});

View file

@ -0,0 +1,102 @@
import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
import zod from 'zod/v4';
import {
ParticipantResponseSchema,
updateParticipantSchema,
} from '../validation';
import {
invalidRequestDataResponse,
notAuthenticatedResponse,
serverReturnedDataValidationErrorResponse,
userNotFoundResponse,
} from '@/lib/defaultApiResponses';
import {
EventIdParamSchema,
UserIdParamSchema,
SuccessResponseSchema,
} from '@/app/api/validation';
export default function registerSwaggerPaths(registry: OpenAPIRegistry) {
registry.registerPath({
method: 'get',
path: '/api/event/{eventID}/participant/{user}',
request: {
params: zod.object({
eventID: EventIdParamSchema,
user: UserIdParamSchema,
}),
},
responses: {
200: {
description: 'Get a participant for the event',
content: {
'application/json': {
schema: ParticipantResponseSchema,
},
},
},
...notAuthenticatedResponse,
...userNotFoundResponse,
...serverReturnedDataValidationErrorResponse,
},
tags: ['Event Participant'],
});
registry.registerPath({
method: 'delete',
path: '/api/event/{eventID}/participant/{user}',
request: {
params: zod.object({
eventID: EventIdParamSchema,
user: UserIdParamSchema,
}),
},
responses: {
200: {
description: 'Participant removed successfully',
content: {
'application/json': {
schema: SuccessResponseSchema,
},
},
},
...notAuthenticatedResponse,
...userNotFoundResponse,
...serverReturnedDataValidationErrorResponse,
},
tags: ['Event Participant'],
});
registry.registerPath({
method: 'patch',
path: '/api/event/{eventID}/participant/{user}',
request: {
params: zod.object({
eventID: EventIdParamSchema,
user: UserIdParamSchema,
}),
body: {
content: {
'application/json': {
schema: updateParticipantSchema,
},
},
},
},
responses: {
200: {
description: 'Participant updated successfully',
content: {
'application/json': {
schema: ParticipantResponseSchema,
},
},
},
...notAuthenticatedResponse,
...userNotFoundResponse,
...serverReturnedDataValidationErrorResponse,
...invalidRequestDataResponse,
},
tags: ['Event Participant'],
});
}

View file

@ -0,0 +1,200 @@
import { prisma } from '@/prisma';
import { auth } from '@/auth';
import {
returnZodTypeCheckedResponse,
userAuthenticated,
} from '@/lib/apiHelpers';
import {
ErrorResponseSchema,
ZodErrorResponseSchema,
} from '@/app/api/validation';
import {
inviteParticipantSchema,
ParticipantResponseSchema,
ParticipantsResponseSchema,
} from './validation';
export const GET = auth(async (req, { params }) => {
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 },
);
const eventID = (await params).eventID;
const isParticipant = await prisma.meetingParticipant.findFirst({
where: {
meeting_id: eventID,
user_id: dbUser.id,
},
select: {
status: true,
},
});
const isOrganizer = await prisma.meeting.findFirst({
where: {
id: eventID,
organizer_id: dbUser.id,
},
select: {
id: true,
},
});
if (!isParticipant && !isOrganizer)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{
success: false,
message: 'User is not a participant or organizer of this event',
},
{ status: 403 },
);
const participants = await prisma.meetingParticipant.findMany({
where: {
meeting_id: eventID,
},
select: {
user: {
select: {
id: true,
name: true,
first_name: true,
last_name: true,
image: true,
timezone: true,
},
},
status: true,
},
});
return returnZodTypeCheckedResponse(ParticipantsResponseSchema, {
success: true,
participants,
});
});
export const POST = auth(async (req, { params }) => {
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 },
);
const eventID = (await params).eventID;
const isOrganizer = await prisma.meeting.findFirst({
where: {
id: eventID,
organizer_id: dbUser.id,
},
});
if (!isOrganizer)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{ success: false, message: 'Only organizers can add participants' },
{ status: 403 },
);
const dataRaw = await req.json();
const data = await inviteParticipantSchema.safeParseAsync(dataRaw);
if (!data.success) {
return returnZodTypeCheckedResponse(
ZodErrorResponseSchema,
{
success: false,
message: 'Invalid request data',
errors: data.error.issues,
},
{ status: 400 },
);
}
const { user_id } = data.data;
const participantExists = await prisma.meetingParticipant.findFirst({
where: {
meeting_id: eventID,
user_id,
},
});
if (participantExists)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{
success: false,
message: 'User is already a participant of this event',
},
{ status: 409 },
);
const newParticipant = await prisma.meetingParticipant.create({
data: {
meeting_id: eventID,
user_id,
},
select: {
user: {
select: {
id: true,
name: true,
first_name: true,
last_name: true,
image: true,
timezone: true,
},
},
status: true,
},
});
return returnZodTypeCheckedResponse(ParticipantResponseSchema, {
success: true,
participant: {
user: {
id: newParticipant.user.id,
name: newParticipant.user.name,
first_name: newParticipant.user.first_name,
last_name: newParticipant.user.last_name,
image: newParticipant.user.image,
timezone: newParticipant.user.timezone,
},
status: newParticipant.status,
},
});
});

View file

@ -0,0 +1,72 @@
import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
import zod from 'zod/v4';
import {
ParticipantsResponseSchema,
ParticipantResponseSchema,
inviteParticipantSchema,
} from './validation';
import {
invalidRequestDataResponse,
notAuthenticatedResponse,
serverReturnedDataValidationErrorResponse,
userNotFoundResponse,
} from '@/lib/defaultApiResponses';
import { EventIdParamSchema } from '@/app/api/validation';
export default function registerSwaggerPaths(registry: OpenAPIRegistry) {
registry.registerPath({
method: 'get',
path: '/api/event/{eventID}/participant',
request: {
params: zod.object({
eventID: EventIdParamSchema,
}),
},
responses: {
200: {
description: 'List participants for the event',
content: {
'application/json': {
schema: ParticipantsResponseSchema,
},
},
},
...notAuthenticatedResponse,
...userNotFoundResponse,
...serverReturnedDataValidationErrorResponse,
},
tags: ['Event Participant'],
});
registry.registerPath({
method: 'post',
path: '/api/event/{eventID}/participant',
request: {
params: zod.object({
eventID: EventIdParamSchema,
}),
body: {
content: {
'application/json': {
schema: inviteParticipantSchema,
},
},
},
},
responses: {
200: {
description: 'Participant invited successfully',
content: {
'application/json': {
schema: ParticipantResponseSchema,
},
},
},
...invalidRequestDataResponse,
...notAuthenticatedResponse,
...userNotFoundResponse,
...serverReturnedDataValidationErrorResponse,
},
tags: ['Event Participant'],
});
}

View file

@ -0,0 +1,50 @@
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
import zod from 'zod/v4';
import {
existingUserIdServerSchema,
PublicUserSchema,
} from '@/app/api/user/validation';
extendZodWithOpenApi(zod);
export const participantStatusSchema = zod.enum([
'ACCEPTED',
'DECLINED',
'TENTATIVE',
'PENDING',
]);
export const inviteParticipantSchema = zod
.object({
user_id: existingUserIdServerSchema,
})
.openapi('InviteParticipant', {
description: 'Schema for inviting a participant to an event',
});
export const updateParticipantSchema = zod
.object({
status: participantStatusSchema,
})
.openapi('UpdateParticipant', {
description: 'Schema for updating participant status in an event',
});
export const ParticipantSchema = zod
.object({
user: PublicUserSchema,
status: participantStatusSchema,
})
.openapi('Participant', {
description: 'Participant information including user and status',
});
export const ParticipantResponseSchema = zod.object({
success: zod.boolean(),
participant: ParticipantSchema,
});
export const ParticipantsResponseSchema = zod.object({
success: zod.boolean(),
participants: zod.array(ParticipantSchema),
});

View file

@ -0,0 +1,318 @@
import { prisma } from '@/prisma';
import { auth } from '@/auth';
import {
returnZodTypeCheckedResponse,
userAuthenticated,
} from '@/lib/apiHelpers';
import {
ErrorResponseSchema,
SuccessResponseSchema,
ZodErrorResponseSchema,
} from '../../validation';
import { EventResponseSchema } from '../validation';
import { updateEventSchema } from '../validation';
export const GET = auth(async (req, { params }) => {
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 },
);
const eventID = (await params).eventID;
const event = await prisma.meeting.findUnique({
where: {
id: eventID,
OR: [
{ organizer_id: dbUser.id },
{ participants: { some: { user_id: dbUser.id } } },
],
},
select: {
id: true,
title: true,
description: true,
start_time: true,
end_time: true,
status: true,
location: true,
organizer_id: true,
created_at: true,
updated_at: true,
organizer: {
select: {
id: true,
name: true,
first_name: true,
last_name: true,
image: true,
timezone: true,
},
},
participants: {
select: {
user: {
select: {
id: true,
name: true,
first_name: true,
last_name: true,
image: true,
timezone: true,
},
},
status: true,
},
},
},
});
if (!event)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{ success: false, message: 'Event not found' },
{ status: 404 },
);
return returnZodTypeCheckedResponse(
EventResponseSchema,
{ success: true, event },
{ status: 200 },
);
});
export const DELETE = auth(async (req, { params }) => {
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 },
);
const eventID = (await params).eventID;
const event = await prisma.meeting.findUnique({
where: {
id: eventID,
OR: [
{ organizer_id: dbUser.id },
{ participants: { some: { user_id: dbUser.id } } },
],
},
});
if (!event)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{ success: false, message: 'Event not found' },
{ status: 404 },
);
if (event.organizer_id !== dbUser.id)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{ success: false, message: 'You are not the organizer of this event' },
{ status: 403 },
);
await prisma.meeting.delete({
where: {
id: eventID,
},
});
return returnZodTypeCheckedResponse(
SuccessResponseSchema,
{ success: true, message: 'Event deleted successfully' },
{ status: 200 },
);
});
export const PATCH = auth(async (req, { params }) => {
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 },
);
const eventID = (await params).eventID;
const event = await prisma.meeting.findUnique({
where: {
id: eventID,
OR: [
{ organizer_id: dbUser.id },
{ participants: { some: { user_id: dbUser.id } } },
],
},
});
if (!event)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{ success: false, message: 'Event not found' },
{ status: 404 },
);
if (event.organizer_id !== dbUser.id)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{ success: false, message: 'You are not the organizer of this event' },
{ status: 403 },
);
const dataRaw = await req.json();
const data = await updateEventSchema.safeParseAsync(dataRaw);
if (!data.success) {
return returnZodTypeCheckedResponse(
ZodErrorResponseSchema,
{
success: false,
message: 'Invalid input data',
errors: data.error.issues,
},
{ status: 400 },
);
}
const {
title,
description,
start_time,
end_time,
location,
status,
participants,
} = data.data;
if (participants !== undefined)
for (const participant of participants) {
await prisma.meetingParticipant.upsert({
where: {
meeting_id_user_id: {
user_id: participant,
meeting_id: eventID,
},
},
create: {
user_id: participant,
meeting_id: eventID,
},
update: {},
});
}
const updatedEvent = await prisma.meeting.update({
where: {
id: eventID,
},
data: {
title,
description,
start_time,
end_time,
location,
status,
participants:
participants !== undefined
? {
deleteMany: {
user_id: {
notIn: participants || [],
},
},
}
: {},
},
select: {
id: true,
title: true,
description: true,
start_time: true,
end_time: true,
status: true,
location: true,
organizer_id: true,
created_at: true,
updated_at: true,
organizer: {
select: {
id: true,
name: true,
first_name: true,
last_name: true,
image: true,
timezone: true,
},
},
participants: {
select: {
user: {
select: {
id: true,
name: true,
first_name: true,
last_name: true,
image: true,
timezone: true,
},
},
status: true,
},
},
},
});
return returnZodTypeCheckedResponse(
EventResponseSchema,
{
success: true,
event: updatedEvent,
},
{ status: 200 },
);
});

View file

@ -0,0 +1,94 @@
import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
import { EventResponseSchema, updateEventSchema } from '../validation';
import {
invalidRequestDataResponse,
notAuthenticatedResponse,
serverReturnedDataValidationErrorResponse,
userNotFoundResponse,
} from '@/lib/defaultApiResponses';
import {
EventIdParamSchema,
SuccessResponseSchema,
} from '@/app/api/validation';
import zod from 'zod/v4';
export default function registerSwaggerPaths(registry: OpenAPIRegistry) {
registry.registerPath({
method: 'get',
path: '/api/event/{eventID}',
request: {
params: zod.object({
eventID: EventIdParamSchema,
}),
},
responses: {
200: {
description: 'Event retrieved successfully',
content: {
'application/json': {
schema: EventResponseSchema,
},
},
},
...userNotFoundResponse,
...serverReturnedDataValidationErrorResponse,
},
tags: ['Event'],
});
registry.registerPath({
method: 'delete',
path: '/api/event/{eventID}',
request: {
params: zod.object({
eventID: EventIdParamSchema,
}),
},
responses: {
200: {
description: 'Event deleted successfully',
content: {
'application/json': {
schema: SuccessResponseSchema,
},
},
},
...notAuthenticatedResponse,
...userNotFoundResponse,
...serverReturnedDataValidationErrorResponse,
},
tags: ['Event'],
});
registry.registerPath({
method: 'patch',
path: '/api/event/{eventID}',
request: {
params: zod.object({
eventID: EventIdParamSchema,
}),
body: {
content: {
'application/json': {
schema: updateEventSchema,
},
},
},
},
responses: {
200: {
description: 'Event updated successfully',
content: {
'application/json': {
schema: EventResponseSchema,
},
},
},
...invalidRequestDataResponse,
...notAuthenticatedResponse,
...userNotFoundResponse,
...serverReturnedDataValidationErrorResponse,
},
tags: ['Event'],
});
}

178
src/app/api/event/route.ts Normal file
View file

@ -0,0 +1,178 @@
import { prisma } from '@/prisma';
import { auth } from '@/auth';
import {
returnZodTypeCheckedResponse,
userAuthenticated,
} from '@/lib/apiHelpers';
import { ErrorResponseSchema, ZodErrorResponseSchema } from '../validation';
import {
createEventSchema,
EventResponseSchema,
EventsResponseSchema,
} from './validation';
export const GET = auth(async (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 },
);
const userEvents = await prisma.meeting.findMany({
where: {
OR: [
{ organizer_id: dbUser.id },
{ participants: { some: { user_id: dbUser.id } } },
],
},
select: {
id: true,
title: true,
description: true,
start_time: true,
end_time: true,
status: true,
location: true,
created_at: true,
updated_at: true,
organizer: {
select: {
id: true,
name: true,
first_name: true,
last_name: true,
image: true,
timezone: true,
},
},
participants: {
select: {
user: {
select: {
id: true,
name: true,
first_name: true,
last_name: true,
image: true,
timezone: true,
},
},
status: true,
},
},
},
});
return returnZodTypeCheckedResponse(
EventsResponseSchema,
{
success: true,
events: userEvents,
},
{ status: 200 },
);
});
export const POST = auth(async (req) => {
const authCheck = userAuthenticated(req);
if (!authCheck.continue)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
authCheck.response,
authCheck.metadata,
);
const dataRaw = await req.json();
const data = await createEventSchema.safeParseAsync(dataRaw);
if (!data.success) {
return returnZodTypeCheckedResponse(
ZodErrorResponseSchema,
{
success: false,
message: 'Invalid request data',
errors: data.error.issues,
},
{ status: 400 },
);
}
const { title, description, start_time, end_time, location, participants } =
data.data;
const newEvent = await prisma.meeting.create({
data: {
title,
description,
start_time,
end_time,
location,
organizer_id: authCheck.user.id!,
participants: participants
? {
create: participants.map((userId) => ({
user: { connect: { id: userId } },
})),
}
: undefined,
},
select: {
id: true,
title: true,
description: true,
start_time: true,
end_time: true,
status: true,
location: true,
created_at: true,
updated_at: true,
organizer: {
select: {
id: true,
name: true,
first_name: true,
last_name: true,
image: true,
timezone: true,
},
},
participants: {
select: {
user: {
select: {
id: true,
name: true,
first_name: true,
last_name: true,
image: true,
timezone: true,
},
},
status: true,
},
},
},
});
return returnZodTypeCheckedResponse(
EventResponseSchema,
{
success: true,
event: newEvent,
},
{ status: 201 },
);
});

View file

@ -0,0 +1,62 @@
import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
import {
EventResponseSchema,
EventsResponseSchema,
createEventSchema,
} from './validation';
import {
invalidRequestDataResponse,
notAuthenticatedResponse,
serverReturnedDataValidationErrorResponse,
userNotFoundResponse,
} from '@/lib/defaultApiResponses';
export default function registerSwaggerPaths(registry: OpenAPIRegistry) {
registry.registerPath({
method: 'get',
path: '/api/event',
responses: {
200: {
description: 'List of events for the authenticated user',
content: {
'application/json': {
schema: EventsResponseSchema,
},
},
},
...notAuthenticatedResponse,
...userNotFoundResponse,
...serverReturnedDataValidationErrorResponse,
},
tags: ['Event'],
});
registry.registerPath({
method: 'post',
path: '/api/event',
request: {
body: {
content: {
'application/json': {
schema: createEventSchema,
},
},
},
},
responses: {
201: {
description: 'Event created successfully.',
content: {
'application/json': {
schema: EventResponseSchema,
},
},
},
...invalidRequestDataResponse,
...notAuthenticatedResponse,
...userNotFoundResponse,
...serverReturnedDataValidationErrorResponse,
},
tags: ['Event'],
});
}

View file

@ -0,0 +1,167 @@
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
import zod from 'zod/v4';
import {
existingUserIdServerSchema,
PublicUserSchema,
} from '../user/validation';
import { ParticipantSchema } from './[eventID]/participant/validation';
extendZodWithOpenApi(zod);
// ----------------------------------------
//
// Event ID Validation
//
// ----------------------------------------
export const eventIdSchema = zod.string().min(1, 'Event ID is required');
// ----------------------------------------
//
// Event Title Validation
//
// ----------------------------------------
export const eventTitleSchema = zod
.string()
.min(1, 'Title is required')
.max(100, 'Title must be at most 100 characters long');
// ----------------------------------------
//
// Event Description Validation
//
// ----------------------------------------
export const eventDescriptionSchema = zod
.string()
.max(500, 'Description must be at most 500 characters long');
// ----------------------------------------
//
// Event start time Validation
//
// ----------------------------------------
export const eventStartTimeSchema = zod.iso
.datetime()
.or(zod.date().transform((date) => date.toISOString()))
.refine((date) => !isNaN(new Date(date).getTime()), {
message: 'Invalid start time',
});
// ----------------------------------------
//
// Event end time Validation
//
// ----------------------------------------
export const eventEndTimeSchema = zod.iso.datetime().or(
zod
.date()
.transform((date) => date.toISOString())
.refine((date) => !isNaN(new Date(date).getTime()), {
message: 'Invalid end time',
}),
);
// ----------------------------------------
//
// Event Location Validation
//
// ----------------------------------------
export const eventLocationSchema = zod
.string()
.max(200, 'Location must be at most 200 characters long');
// ----------------------------------------
//
// Event Participants Validation
//
// ----------------------------------------
export const eventParticipantsSchema = zod.array(existingUserIdServerSchema);
// ----------------------------------------
//
// Event Status Validation
//
// ----------------------------------------
export const eventStatusSchema = zod.enum([
'TENTATIVE',
'CONFIRMED',
'CANCELLED',
]);
// ----------------------------------------
//
// Create Event Schema
//
// ----------------------------------------
export const createEventSchema = zod
.object({
title: eventTitleSchema,
description: eventDescriptionSchema.optional(),
start_time: eventStartTimeSchema,
end_time: eventEndTimeSchema,
location: eventLocationSchema.optional().default(''),
participants: eventParticipantsSchema.optional(),
status: eventStatusSchema.optional().default('TENTATIVE'),
})
.refine((data) => new Date(data.start_time) < new Date(data.end_time), {
message: 'Start time must be before end time',
});
// ----------------------------------------
//
// Update Event Schema
//
// ----------------------------------------
export const updateEventSchema = zod
.object({
title: eventTitleSchema.optional(),
description: eventDescriptionSchema.optional(),
start_time: eventStartTimeSchema.optional(),
end_time: eventEndTimeSchema.optional(),
location: eventLocationSchema.optional().default(''),
participants: eventParticipantsSchema.optional(),
status: eventStatusSchema.optional(),
})
.refine(
(data) => {
if (data.start_time && data.end_time) {
return new Date(data.start_time) < new Date(data.end_time);
}
return true;
},
{
message: 'Start time must be before end time',
},
);
// ----------------------------------------
//
// Event Schema Validation (for API responses)
//
// ----------------------------------------
export const EventSchema = zod
.object({
id: eventIdSchema,
title: eventTitleSchema,
description: eventDescriptionSchema.nullish(),
start_time: eventStartTimeSchema,
end_time: eventEndTimeSchema,
location: eventLocationSchema.nullish(),
status: eventStatusSchema,
created_at: zod.date(),
updated_at: zod.date(),
organizer: PublicUserSchema,
participants: zod.array(ParticipantSchema).nullish(),
})
.openapi('Event', {
description: 'Event information including all fields',
});
export const EventResponseSchema = zod.object({
success: zod.boolean(),
event: EventSchema,
});
export const EventsResponseSchema = zod.object({
success: zod.boolean(),
events: zod.array(EventSchema),
});

View file

@ -0,0 +1,79 @@
import { auth } from '@/auth';
import { prisma } from '@/prisma';
import { searchUserSchema, searchUserResponseSchema } from './validation';
import {
returnZodTypeCheckedResponse,
userAuthenticated,
} from '@/lib/apiHelpers';
import {
ErrorResponseSchema,
ZodErrorResponseSchema,
} from '@/app/api/validation';
export const GET = auth(async function GET(req) {
const authCheck = userAuthenticated(req);
if (!authCheck.continue)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
authCheck.response,
authCheck.metadata,
);
const dataRaw = Object.fromEntries(new URL(req.url).searchParams);
const data = await searchUserSchema.safeParseAsync(dataRaw);
if (!data.success)
return returnZodTypeCheckedResponse(
ZodErrorResponseSchema,
{
success: false,
message: 'Invalid request data',
errors: data.error.issues,
},
{ status: 400 },
);
const { query, count, page, sort_by, sort_order } = data.data;
const dbUsers = await prisma.user.findMany({
where: {
OR: [
{ name: { contains: query } },
{ first_name: { contains: query } },
{ last_name: { contains: query } },
],
},
orderBy: {
[sort_by]: sort_order,
},
skip: (page - 1) * count,
take: count,
select: {
id: true,
name: true,
first_name: true,
last_name: true,
timezone: true,
image: true,
},
});
const userCount = await prisma.user.count({
where: {
OR: [
{ name: { contains: query } },
{ first_name: { contains: query } },
{ last_name: { contains: query } },
],
},
});
return returnZodTypeCheckedResponse(
searchUserResponseSchema,
{
success: true,
users: dbUsers,
total_count: userCount,
total_pages: Math.ceil(userCount / count),
},
{ status: 200 },
);
});

View file

@ -0,0 +1,33 @@
import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
import { searchUserResponseSchema, searchUserSchema } from './validation';
import {
invalidRequestDataResponse,
notAuthenticatedResponse,
serverReturnedDataValidationErrorResponse,
userNotFoundResponse,
} from '@/lib/defaultApiResponses';
export default function registerSwaggerPaths(registry: OpenAPIRegistry) {
registry.registerPath({
method: 'get',
path: '/api/search/user',
request: {
query: searchUserSchema,
},
responses: {
200: {
description: 'User search results',
content: {
'application/json': {
schema: searchUserResponseSchema,
},
},
},
...invalidRequestDataResponse,
...notAuthenticatedResponse,
...userNotFoundResponse,
...serverReturnedDataValidationErrorResponse,
},
tags: ['Search'],
});
}

View file

@ -0,0 +1,20 @@
import zod from 'zod/v4';
import { PublicUserSchema } from '../../user/validation';
export const searchUserSchema = zod.object({
query: zod.string().optional().default(''),
count: zod.coerce.number().min(1).max(100).default(10),
page: zod.coerce.number().min(1).default(1),
sort_by: zod
.enum(['created_at', 'name', 'first_name', 'last_name', 'id'])
.optional()
.default('created_at'),
sort_order: zod.enum(['asc', 'desc']).optional().default('desc'),
});
export const searchUserResponseSchema = zod.object({
success: zod.boolean(),
users: zod.array(PublicUserSchema),
total_count: zod.number(),
total_pages: zod.number(),
});

View file

@ -0,0 +1,212 @@
import { auth } from '@/auth';
import { prisma } from '@/prisma';
import {
returnZodTypeCheckedResponse,
userAuthenticated,
} from '@/lib/apiHelpers';
import {
userCalendarQuerySchema,
UserCalendarResponseSchema,
UserCalendarSchema,
} from './validation';
import {
ErrorResponseSchema,
ZodErrorResponseSchema,
} from '@/app/api/validation';
import { z } from 'zod/v4';
export const GET = auth(async function GET(req, { params }) {
const authCheck = userAuthenticated(req);
if (!authCheck.continue)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
authCheck.response,
authCheck.metadata,
);
const dataRaw = Object.fromEntries(new URL(req.url).searchParams);
const data = await userCalendarQuerySchema.safeParseAsync(dataRaw);
if (!data.success)
return returnZodTypeCheckedResponse(
ZodErrorResponseSchema,
{
success: false,
message: 'Invalid request data',
errors: data.error.issues,
},
{ status: 400 },
);
const { end, start } = data.data;
const requestUserId = authCheck.user.id;
const requestedUserId = (await params).user;
const requestedUser = await prisma.user.findFirst({
where: {
id: requestedUserId,
},
select: {
meetingParts: {
where: {
meeting: {
start_time: {
lte: end,
},
end_time: {
gte: start,
},
},
},
orderBy: {
meeting: {
start_time: 'asc',
},
},
select: {
meeting: {
select: {
id: true,
title: true,
description: true,
start_time: true,
end_time: true,
status: true,
location: true,
created_at: true,
updated_at: true,
organizer_id: true,
participants: {
select: {
user: {
select: {
id: true,
},
},
},
},
},
},
},
},
meetingsOrg: {
where: {
start_time: {
lte: end,
},
end_time: {
gte: start,
},
},
orderBy: {
start_time: 'asc',
},
select: {
id: true,
title: true,
description: true,
start_time: true,
end_time: true,
status: true,
location: true,
created_at: true,
updated_at: true,
organizer_id: true,
participants: {
select: {
user: {
select: {
id: true,
},
},
},
},
},
},
blockedSlots: {
where: {
start_time: {
lte: end,
},
end_time: {
gte: start,
},
},
orderBy: {
start_time: 'asc',
},
select: {
id: requestUserId === requestedUserId ? true : false,
reason: requestUserId === requestedUserId ? true : false,
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,
},
},
},
});
if (!requestedUser)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{ success: false, message: 'User not found' },
{ status: 404 },
);
const calendar: z.input<typeof UserCalendarSchema> = [];
for (const event of requestedUser.meetingParts) {
if (
event.meeting.participants.some((p) => p.user.id === requestUserId) ||
event.meeting.organizer_id === requestUserId
) {
calendar.push({ ...event.meeting, type: 'event' });
} else {
calendar.push({
start_time: event.meeting.start_time,
end_time: event.meeting.end_time,
type: 'blocked_private',
});
}
}
for (const event of requestedUser.meetingsOrg) {
if (
event.participants.some((p) => p.user.id === requestUserId) ||
event.organizer_id === requestUserId
) {
calendar.push({ ...event, type: 'event' });
} else {
calendar.push({
start_time: event.start_time,
end_time: event.end_time,
type: 'blocked_private',
});
}
}
for (const slot of requestedUser.blockedSlots) {
calendar.push({
start_time: slot.start_time,
end_time: slot.end_time,
id: slot.id,
reason: slot.reason,
is_recurring: slot.is_recurring,
recurrence_end_date: slot.recurrence_end_date,
rrule: slot.rrule,
created_at: slot.created_at,
updated_at: slot.updated_at,
type:
requestUserId === requestedUserId ? 'blocked_owned' : 'blocked_private',
});
}
return returnZodTypeCheckedResponse(UserCalendarResponseSchema, {
success: true,
calendar,
});
});

View file

@ -0,0 +1,37 @@
import {
userCalendarQuerySchema,
UserCalendarResponseSchema,
} from './validation';
import {
notAuthenticatedResponse,
userNotFoundResponse,
} from '@/lib/defaultApiResponses';
import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
import zod from 'zod/v4';
import { UserIdParamSchema } from '@/app/api/validation';
export default function registerSwaggerPaths(registry: OpenAPIRegistry) {
registry.registerPath({
method: 'get',
path: '/api/user/{user}/calendar',
request: {
params: zod.object({
user: UserIdParamSchema,
}),
query: userCalendarQuerySchema,
},
responses: {
200: {
description: 'User calendar retrieved successfully.',
content: {
'application/json': {
schema: UserCalendarResponseSchema,
},
},
},
...notAuthenticatedResponse,
...userNotFoundResponse,
},
tags: ['User'],
});
}

View file

@ -0,0 +1,99 @@
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
import zod from 'zod/v4';
import {
eventEndTimeSchema,
EventSchema,
eventStartTimeSchema,
} from '@/app/api/event/validation';
extendZodWithOpenApi(zod);
export const BlockedSlotSchema = zod
.object({
start_time: eventStartTimeSchema,
end_time: eventEndTimeSchema,
type: zod.literal('blocked_private'),
})
.openapi('BlockedSlotSchema', {
description: 'Blocked time slot in the user calendar',
});
export const OwnedBlockedSlotSchema = BlockedSlotSchema.extend({
id: zod.string(),
reason: zod.string().nullish(),
is_recurring: zod.boolean().default(false),
recurrence_end_date: zod.date().nullish(),
rrule: zod.string().nullish(),
created_at: zod.date().nullish(),
updated_at: zod.date().nullish(),
type: zod.literal('blocked_owned'),
}).openapi('OwnedBlockedSlotSchema', {
description: 'Blocked slot owned by the user',
});
export const VisibleSlotSchema = EventSchema.omit({
organizer: true,
participants: true,
})
.extend({
type: zod.literal('event'),
})
.openapi('VisibleSlotSchema', {
description: 'Visible time slot in the user calendar',
});
export const UserCalendarSchema = zod
.array(VisibleSlotSchema.or(BlockedSlotSchema).or(OwnedBlockedSlotSchema))
.openapi('UserCalendarSchema', {
description: 'Array of events in the user calendar',
});
export const UserCalendarResponseSchema = zod.object({
success: zod.boolean().default(true),
calendar: UserCalendarSchema,
});
export const userCalendarQuerySchema = zod
.object({
start: zod.iso
.datetime()
.optional()
.transform((val) => {
if (val) return new Date(val);
const now = new Date();
const startOfWeek = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate() - now.getDay(),
);
return startOfWeek;
}),
end: zod.iso
.datetime()
.optional()
.transform((val) => {
if (val) return new Date(val);
const now = new Date();
const endOfWeek = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate() + (6 - now.getDay()),
);
return endOfWeek;
}),
})
.openapi('UserCalendarQuerySchema', {
description: 'Query parameters for filtering the user calendar',
properties: {
start: {
type: 'string',
format: 'date-time',
description: 'Start date for filtering the calendar events',
},
end: {
type: 'string',
format: 'date-time',
description: 'End date for filtering the calendar events',
},
},
});

View file

@ -0,0 +1,55 @@
import { auth } from '@/auth';
import { prisma } from '@/prisma';
import {
returnZodTypeCheckedResponse,
userAuthenticated,
} from '@/lib/apiHelpers';
import { PublicUserResponseSchema } from '../validation';
import { ErrorResponseSchema } from '@/app/api/validation';
export const GET = auth(async function GET(req, { params }) {
const authCheck = userAuthenticated(req);
if (!authCheck.continue)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
authCheck.response,
authCheck.metadata,
);
const requestedUser = (await params).user;
const dbUser = await prisma.user.findFirst({
where: {
OR: [{ id: requestedUser }, { name: requestedUser }],
},
select: {
id: true,
name: true,
first_name: true,
last_name: true,
email: true,
created_at: true,
updated_at: true,
image: true,
timezone: true,
},
});
if (!dbUser)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{
success: false,
message: 'User not found',
},
{ status: 404 },
);
return returnZodTypeCheckedResponse(
PublicUserResponseSchema,
{
success: true,
user: dbUser,
},
{ status: 200 },
);
});

View file

@ -0,0 +1,33 @@
import { PublicUserResponseSchema } from '../validation';
import {
notAuthenticatedResponse,
userNotFoundResponse,
} from '@/lib/defaultApiResponses';
import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
import zod from 'zod/v4';
import { UserIdParamSchema } from '../../validation';
export default function registerSwaggerPaths(registry: OpenAPIRegistry) {
registry.registerPath({
method: 'get',
path: '/api/user/{user}',
request: {
params: zod.object({
user: UserIdParamSchema,
}),
},
responses: {
200: {
description: 'User information retrieved successfully.',
content: {
'application/json': {
schema: PublicUserResponseSchema,
},
},
},
...notAuthenticatedResponse,
...userNotFoundResponse,
},
tags: ['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

@ -0,0 +1,160 @@
import { auth } from '@/auth';
import { prisma } from '@/prisma';
import { updateUserServerSchema } from './validation';
import {
returnZodTypeCheckedResponse,
userAuthenticated,
} from '@/lib/apiHelpers';
import { FullUserResponseSchema } from '../validation';
import {
ErrorResponseSchema,
SuccessResponseSchema,
ZodErrorResponseSchema,
} from '@/app/api/validation';
export const GET = auth(async function GET(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,
},
select: {
id: true,
name: true,
first_name: true,
last_name: true,
email: true,
image: true,
timezone: true,
created_at: true,
updated_at: true,
},
});
if (!dbUser)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{
success: false,
message: 'User not found',
},
{ status: 404 },
);
return returnZodTypeCheckedResponse(FullUserResponseSchema, {
success: true,
user: dbUser,
});
});
export const PATCH = auth(async function PATCH(req) {
const authCheck = userAuthenticated(req);
if (!authCheck.continue)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
authCheck.response,
authCheck.metadata,
);
const dataRaw = await req.json();
const data = await updateUserServerSchema.safeParseAsync(dataRaw);
if (!data.success) {
return returnZodTypeCheckedResponse(
ZodErrorResponseSchema,
{
success: false,
message: 'Invalid request data',
errors: data.error.issues,
},
{ status: 400 },
);
}
if (Object.keys(data.data).length === 0) {
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{ success: false, message: 'No data to update' },
{ status: 400 },
);
}
const updatedUser = await prisma.user.update({
where: {
id: authCheck.user.id,
},
data: data.data,
select: {
id: true,
name: true,
first_name: true,
last_name: true,
email: true,
image: true,
timezone: true,
created_at: true,
updated_at: true,
},
});
if (!updatedUser)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{
success: false,
message: 'User not found',
},
{ status: 404 },
);
return returnZodTypeCheckedResponse(
FullUserResponseSchema,
{
success: true,
user: updatedUser,
},
{ 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

@ -0,0 +1,84 @@
import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
import { FullUserResponseSchema } from '../validation';
import { updateUserServerSchema } from './validation';
import {
invalidRequestDataResponse,
notAuthenticatedResponse,
serverReturnedDataValidationErrorResponse,
userNotFoundResponse,
} from '@/lib/defaultApiResponses';
import { SuccessResponseSchema } from '../../validation';
export default function registerSwaggerPaths(registry: OpenAPIRegistry) {
registry.registerPath({
method: 'get',
path: '/api/user/me',
description: 'Get the currently authenticated user',
responses: {
200: {
description: 'User information retrieved successfully',
content: {
'application/json': {
schema: FullUserResponseSchema,
},
},
},
...notAuthenticatedResponse,
...userNotFoundResponse,
...serverReturnedDataValidationErrorResponse,
},
tags: ['User'],
});
registry.registerPath({
method: 'patch',
path: '/api/user/me',
description: 'Update the currently authenticated user',
request: {
body: {
description: 'User information to update',
required: true,
content: {
'application/json': {
schema: updateUserServerSchema,
},
},
},
},
responses: {
200: {
description: 'User information updated successfully',
content: {
'application/json': {
schema: FullUserResponseSchema,
},
},
},
...invalidRequestDataResponse,
...notAuthenticatedResponse,
...userNotFoundResponse,
...serverReturnedDataValidationErrorResponse,
},
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

@ -0,0 +1,33 @@
import zod from 'zod/v4';
import {
firstNameSchema,
lastNameSchema,
newUserEmailServerSchema,
newUserNameServerSchema,
passwordSchema,
timezoneSchema,
} from '@/app/api/user/validation';
// ----------------------------------------
//
// Update User Validation
//
// ----------------------------------------
export const updateUserServerSchema = zod.object({
name: newUserNameServerSchema.optional(),
first_name: firstNameSchema.optional(),
last_name: lastNameSchema.optional(),
email: newUserEmailServerSchema.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

@ -0,0 +1,162 @@
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
import { prisma } from '@/prisma';
import zod from 'zod/v4';
import { allTimeZones } from '@/lib/timezones';
extendZodWithOpenApi(zod);
// ----------------------------------------
//
// Email Validation
//
// ----------------------------------------
export const emailSchema = zod
.email('Invalid email address')
.min(3, 'Email is required');
export const newUserEmailServerSchema = emailSchema.refine(async (val) => {
const existingUser = await prisma.user.findUnique({
where: { email: val },
});
return !existingUser;
}, 'Email in use by another account');
export const existingUserEmailServerSchema = emailSchema.refine(async (val) => {
const existingUser = await prisma.user.findUnique({
where: { email: val },
});
return !!existingUser;
}, 'Email not found');
// ----------------------------------------
//
// First Name Validation
//
// ----------------------------------------
export const firstNameSchema = zod
.string()
.min(1, 'First name is required')
.max(32, 'First name must be at most 32 characters long');
// ----------------------------------------
//
// Last Name Validation
//
// ----------------------------------------
export const lastNameSchema = zod
.string()
.min(1, 'Last name is required')
.max(32, 'Last name must be at most 32 characters long');
// ----------------------------------------
//
// Username Validation
//
// ----------------------------------------
export const userNameSchema = zod
.string()
.min(3, 'Username is required')
.max(32, 'Username must be at most 32 characters long')
.regex(
/^[a-zA-Z0-9_]+$/,
'Username can only contain letters, numbers, and underscores',
);
export const newUserNameServerSchema = userNameSchema.refine(async (val) => {
const existingUser = await prisma.user.findUnique({
where: { name: val },
});
return !existingUser;
}, 'Username in use by another account');
export const existingUserNameServerSchema = userNameSchema.refine(
async (val) => {
const existingUser = await prisma.user.findUnique({
where: { name: val },
});
return !!existingUser;
},
'Username not found',
);
// ----------------------------------------
//
// User ID Validation
//
// ----------------------------------------
export const existingUserIdServerSchema = zod
.string()
.min(1, 'User ID is required')
.refine(async (val) => {
const user = await prisma.user.findUnique({
where: { id: val },
});
return !!user;
}, 'User not found');
// ----------------------------------------
//
// Password Validation
//
// ----------------------------------------
export const passwordSchema = zod
.string()
.min(8, 'Password must be at least 8 characters long')
.max(128, 'Password must be at most 128 characters long')
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+={}\[\]:;"'<>,.?\/\\-]).{8,}$/,
'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)
//
// ----------------------------------------
export const FullUserSchema = zod
.object({
id: zod.string(),
name: zod.string(),
first_name: zod.string().nullish(),
last_name: zod.string().nullish(),
email: zod.email(),
image: zod.url().nullish(),
timezone: zod
.string()
.refine((i) => (allTimeZones as string[]).includes(i))
.nullish(),
created_at: zod.date(),
updated_at: zod.date(),
})
.openapi('FullUser', {
description: 'Full user information including all fields',
});
export const PublicUserSchema = FullUserSchema.pick({
id: true,
name: true,
first_name: true,
last_name: true,
image: true,
timezone: true,
}).openapi('PublicUser', {
description: 'Public user information excluding sensitive data',
});
export const FullUserResponseSchema = zod.object({
success: zod.boolean(),
user: FullUserSchema,
});
export const PublicUserResponseSchema = zod.object({
success: zod.boolean(),
user: PublicUserSchema,
});

87
src/app/api/validation.ts Normal file
View file

@ -0,0 +1,87 @@
import { registry } from '@/lib/swagger';
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
import zod from 'zod/v4';
extendZodWithOpenApi(zod);
export const ErrorResponseSchema = zod
.object({
success: zod.boolean(),
message: zod.string(),
})
.openapi('ErrorResponseSchema', {
description: 'Error response schema',
example: {
success: false,
message: 'An error occurred',
},
});
export const ZodErrorResponseSchema = ErrorResponseSchema.extend({
errors: zod.array(
zod.object({
expected: zod.string().optional(),
code: zod.string(),
path: zod.array(
zod
.string()
.or(zod.number())
.or(
zod.symbol().openapi({
type: 'string',
}),
),
),
message: zod.string(),
}),
),
}).openapi('ZodErrorResponseSchema', {
description: 'Zod error response schema',
example: {
success: false,
message: 'Invalid request data',
errors: [
{
expected: 'string',
code: 'invalid_type',
path: ['first_name'],
message: 'Invalid input: expected string, received number',
},
],
},
});
export const SuccessResponseSchema = zod
.object({
success: zod.boolean(),
message: zod.string().optional(),
})
.openapi('SuccessResponseSchema', {
description: 'Success response schema',
example: {
success: true,
message: 'Operation completed successfully',
},
});
export const UserIdParamSchema = registry.registerParameter(
'UserIdOrNameParam',
zod.string().openapi({
param: {
name: 'user',
in: 'path',
},
example: '12345',
}),
);
export const EventIdParamSchema = registry.registerParameter(
'EventIdParam',
zod.string().openapi({
param: {
name: 'eventID',
in: 'path',
},
example: '67890',
}),
);

View file

@ -4,30 +4,76 @@
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
/* Custom values */
--background: oklch(1 0 0);
--font-heading: 'Comfortaa', sans-serif;
--font-label: 'Varela Round', sans-serif;
--font-button: 'Varela Round', sans-serif;
--transparent: transparent;
--neutral-000: oklch(0 0 0);
--neutral-100: oklch(0.2264 0 0);
--neutral-150: oklch(0.2972 0 0);
--neutral-200: oklch(0.3407 0 0);
--neutral-300: oklch(0.4495 0 0);
--neutral-400: oklch(0.5486 0 0);
--neutral-450: oklch(0.6 0 0);
--neutral-500: oklch(0.6434 0 0);
--neutral-600: oklch(0.738 0 0);
--neutral-700: oklch(0.8266 0 0);
--neutral-750: oklch(0.9128 0 0);
--neutral-800: oklch(0.9702 0 0);
--neutral-900: oklch(1 0 0);
--background: var(--neutral-800);
--background-reversed: var(--neutral-000);
--base: 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);
--radius: 0.688rem;
--primary: oklch(0.7493 0.1551 74.95);
--hover-primary: oklch(0.7493 0.1551 74.95 / 0.8);
--active-primary: oklch(0.6191 0.1218 77.58);
--disabled-primary: oklch(0.7493 0.1551 74.95 / 0.5);
--secondary: oklch(0.4937 0.1697 271.26);
--hover-secondary: oklch(0.4937 0.1697 271.26 / 0.8);
--active-secondary: oklch(0.4254 0.133 272.15);
--disabled-secondary: oklch(0.4937 0.1697 271.26 / 0.5);
--muted: var(--color-neutral-700);
--hover-muted: var(--color-neutral-600);
--active-muted: var(--color-neutral-400);
--disabled-muted: var(--color-neutral-400);
--card: var(--neutral-800);
--sidebar-width-icon: 32px;
/* ------------------- */
--foreground: oklch(0.13 0.028 261.692);
--card: oklch(1 0 0);
--card-foreground: oklch(0.13 0.028 261.692);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.13 0.028 261.692);
--primary: oklch(0.21 0.034 264.665);
--primary-foreground: oklch(0.985 0.002 247.839);
--secondary: oklch(0.967 0.003 264.542);
--popover-hover: var(--neutral-750);
--secondary-foreground: oklch(0.21 0.034 264.665);
--muted: oklch(0.967 0.003 264.542);
--muted-foreground: oklch(0.551 0.027 264.364);
--accent: oklch(0.967 0.003 264.542);
@ -52,24 +98,90 @@
--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);
}
@font-face {
font-family: 'Comfortaa';
font-style: normal;
font-weight: 300 700;
src: url('/fonts/Comfortaa/Comfortaa-VariableFont_weight.ttf')
format('truetype');
}
@font-face {
font-family: 'Varela Round';
font-style: normal;
font-weight: 400;
src: url('/fonts/VarelaRound/VarelaRound-Regular.ttf') format('truetype');
}
@theme inline {
--font-heading: var(--font-heading);
--font-label: var(--font-label);
--font-button: var(--font-button);
--transparent: var(--transparent);
--color-neutral-000: var(--neutral-000);
--color-neutral-100: var(--neutral-100);
--color-neutral-150: var(--neutral-150);
--color-neutral-200: var(--neutral-200);
--color-neutral-300: var(--neutral-300);
--color-neutral-400: var(--neutral-400);
--color-neutral-500: var(--neutral-500);
--color-neutral-600: var(--neutral-600);
--color-neutral-650: var(--neutral-650);
--color-neutral-700: var(--neutral-700);
--color-neutral-750: var(--neutral-750);
--color-neutral-800: var(--neutral-800);
--color-neutral-900: var(--neutral-900);
--color-background: var(--neutral-750);
--color-background-reversed: var(--background-reversed);
--color-base: 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);
--color-text-disabled: var(--neutral-700);
--radius: 0.688rem;
--color-primary: var(--primary);
--color-hover-primary: var(--hover-primary);
--color-active-primary: var(--active-primary);
--color-disabled-primary: var(--disabled-primary);
--color-secondary: var(--secondary);
--color-hover-secondary: var(--hover-secondary);
--color-active-secondary: var(--active-secondary);
--color-disabled-secondary: var(--disabled-secondary);
--color-muted: var(--muted);
--color-hover-muted: var(--hover-muted);
--color-active-muted: var(--active-muted);
--color-disabled-muted: var(--disabled-muted);
/* Custom values */
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
@ -86,10 +198,12 @@
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover: var(--color-background);
--color-popover-foreground: var(--popover-foreground);
--color-popover-hover: var(--popover-hover);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
@ -102,7 +216,7 @@
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent: var(--color-neutral-750);
--color-accent-foreground: var(--accent-foreground);
@ -142,28 +256,70 @@
}
.dark {
--background: oklch(0.13 0.028 261.692);
/* Custom values */
--transparent: transparent;
--neutral-000: oklch(1 0 0);
--neutral-100: oklch(0.9702 0 0);
--neutral-150: oklch(0.9128 0 0);
--neutral-200: oklch(0.8266 0 0);
--neutral-300: oklch(0.738 0 0);
--neutral-400: oklch(0.6434 0 0);
--neutral-450: oklch(0.6 0 0);
--neutral-500: oklch(0.5486 0 0);
--neutral-600: oklch(0.4495 0 0);
--neutral-650: oklch(0.425 0 0);
--neutral-700: oklch(0.3407 0 0);
--neutral-750: oklch(0.2972 0 0);
--neutral-800: oklch(0.2264 0 0);
--neutral-900: oklch(0 0 0);
--background: var(--neutral-750);
--background-reversed: var(--neutral-000);
--base: 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);
--primary: oklch(0.6568 0.1358 74.86);
--hover-primary: oklch(0.6568 0.1358 74.86 / 0.8);
--active-primary: oklch(0.5274 0.0997 78.52);
--disabled-primary: oklch(0.6568 0.1358 74.86 / 0.4);
--secondary: oklch(0.6065 0.213 271.11);
--hover-secondary: oklch(0.6065 0.213 271.11 / 0.8);
--active-secondary: oklch(0.4471 0.15 271.61);
--disabled-secondary: oklch(0.6065 0.213 271.11 / 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);
--card: var(--neutral-750);
/* ------------------- */
--foreground: oklch(0.985 0.002 247.839);
--card: oklch(0.21 0.034 264.665);
--card-foreground: oklch(0.985 0.002 247.839);
--popover: oklch(0.21 0.034 264.665);
--popover-foreground: oklch(0.985 0.002 247.839);
--primary: oklch(0.928 0.006 264.531);
--primary-foreground: oklch(0.21 0.034 264.665);
--secondary: oklch(0.278 0.033 256.848);
--popover-hover: var(--neutral-700);
--secondary-foreground: oklch(0.985 0.002 247.839);
--muted: oklch(0.278 0.033 256.848);
--muted-foreground: oklch(0.707 0.022 261.325);
--accent: oklch(0.278 0.033 256.848);
@ -188,17 +344,17 @@
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.034 264.665);
--sidebar: var(--background);
--sidebar-foreground: oklch(0.985 0.002 247.839);
--sidebar-foreground: var(--text);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0.002 247.839);
--sidebar-primary-foreground: var(--text);
--sidebar-accent: oklch(0.278 0.033 256.848);
--sidebar-accent-foreground: oklch(0.985 0.002 247.839);
--sidebar-accent-foreground: var(--text);
--sidebar-border: oklch(1 0 0 / 10%);
@ -213,3 +369,8 @@
@apply bg-background text-foreground;
}
}
/* Fix for swagger ui readability */
body:has(.swagger-ui) {
@apply bg-white text-black;
}

View file

@ -1,15 +0,0 @@
import { RedirectButton } from '@/components/user/redirect-button';
import { ThemePicker } from '@/components/user/theme-picker';
export default function Home() {
return (
<div className='flex flex-col items-center justify-center h-screen'>
<div className='absolute top-4 right-4'>{<ThemePicker />}</div>
<div>
<h1>Home</h1>
<RedirectButton redirectUrl='/logout' buttonText='Logout' />
<RedirectButton redirectUrl='/settings' buttonText='Settings' />
</div>
</div>
);
}

View file

@ -1,7 +1,8 @@
import { ThemeProvider } from '@/components/theme-provider';
import { ThemeProvider } from '@/components/wrappers/theme-provider';
import type { Metadata } from 'next';
import './globals.css';
import { QueryProvider } from '@/components/wrappers/query-provider';
export const metadata: Metadata = {
title: 'MeetUp',
@ -15,6 +16,39 @@ export default function RootLayout({
}>) {
return (
<html lang='en' suppressHydrationWarning>
<head>
<link
rel='icon'
type='image/png'
href='/favicon-dark.png'
media='(prefers-color-scheme: dark)'
/>
<link
rel='icon'
type='image/png'
href='/favicon-light.png'
media='(prefers-color-scheme: light)'
/>
<link
rel='icon'
type='image/svg+xml'
href='/favicon-dark.svg'
media='(prefers-color-scheme: dark)'
/>
<link
rel='icon'
type='image/svg+xml'
href='/favicon-light.svg'
media='(prefers-color-scheme: light)'
/>
<link rel='shortcut icon' href='/favicon.ico' />
<link
rel='apple-touch-icon'
sizes='180x180'
href='/apple-touch-icon.png'
/>
<link rel='manifest' href='/site.webmanifest' />
</head>
<body>
<ThemeProvider
attribute='class'
@ -22,7 +56,7 @@ export default function RootLayout({
enableSystem
disableTransitionOnChange
>
{children}
<QueryProvider>{children}</QueryProvider>
</ThemeProvider>
</body>
</html>

View file

@ -1,13 +1,17 @@
import { auth, providerMap } from '@/auth';
import SSOLogin from '@/components/user/sso-login-button';
import LoginForm from '@/components/user/login-form';
import SSOLogin from '@/components/buttons/sso-login-button';
import LoginForm from '@/components/forms/login-form';
import { redirect } from 'next/navigation';
import { Button } from '@/components/ui/button';
import Image from 'next/image';
import '@/app/globals.css';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ThemePicker } from '@/components/user/theme-picker';
import { Separator } from '@/components/ui/separator';
import Logo from '@/components/misc/logo';
import {
Card,
CardContent,
CardHeader,
} from '@/components/custom-ui/login-card';
import { ThemePicker } from '@/components/misc/theme-picker';
import {
HoverCard,
HoverCardTrigger,
@ -22,22 +26,23 @@ export default async function LoginPage() {
}
return (
<div className='flex flex-col items-center justify-center h-screen'>
<div className='flex flex-col items-center justify-center h-screen'>
<div className='absolute top-4 right-4'>
<div className='flex flex-col items-center max-h-screen overflow-y-auto'>
<div className='flex flex-col items-center min-h-screen'>
<div className='fixed top-4 right-4'>
<ThemePicker />
</div>
<div>
<Card className='w-[350px] max-w-screen'>
<CardHeader>
<CardTitle className='text-lg text-center' data-cy='login-header'>
Login
</CardTitle>
<div className='mt-auto mb-auto'>
<Card className='w-[350px] max-w-screen;'>
<CardHeader
className='grid place-items-center'
data-cy='login-header'
>
<Logo colorType='colored' logoType='secondary'></Logo>
</CardHeader>
<CardContent className='gap-6 flex flex-col'>
<CardContent className='gap-6 flex flex-col items-center'>
<LoginForm />
{providerMap.length > 0 && <hr />}
<Separator className='h-[1px] rounded-sm w-[60%] bg-border' />
{providerMap.map((provider) => (
<SSOLogin
@ -50,20 +55,20 @@ export default async function LoginPage() {
</CardContent>
</Card>
</div>
<HoverCard>
<HoverCardTrigger>
<Button variant='link'>made with love</Button>
</HoverCardTrigger>
<HoverCardContent className='flex items-center justify-center'>
<Image
src='https://img1.wikia.nocookie.net/__cb20140808110649/clubpenguin/images/a/a1/Action_Dance_Light_Blue.gif'
width='150'
height='150'
alt='dancing penguin'
></Image>
</HoverCardContent>
</HoverCard>
</div>
<HoverCard>
<HoverCardTrigger className='text-sm text-muted-foreground hover:underline'>
<Button variant='link'>made with love</Button>
</HoverCardTrigger>
<HoverCardContent className='flex items-center justify-center'>
<Image
src='https://img1.wikia.nocookie.net/__cb20140808110649/clubpenguin/images/a/a1/Action_Dance_Light_Blue.gif'
width='150'
height='150'
alt='dancing penguin'
></Image>
</HoverCardContent>
</HoverCard>
</div>
);
}

View file

@ -25,11 +25,7 @@ export default function SignOutPage() {
</CardDescription>
</CardHeader>
<CardContent className='gap-6 flex flex-col'>
<Button
className='hover:bg-blue-600 hover:text-white'
type='submit'
variant='secondary'
>
<Button type='submit' variant='secondary'>
Logout
</Button>
</CardContent>

View file

@ -88,7 +88,7 @@ export default function SettingsPage() {
</Select>
</div>
<div className='pt-4'>
<Button variant='destructive'>Delete Account</Button>
<Button variant='secondary'>Delete Account</Button>
<p className='text-sm text-muted-foreground pt-1'>
Permanently delete your account and all associated data.
</p>
@ -251,7 +251,7 @@ export default function SettingsPage() {
Define your typical available hours (e.g.,
Monday-Friday, 9 AM - 5 PM).
</p>
<Button variant='outline' size='sm'>
<Button variant='outline_muted' size='sm'>
Set Working Hours
</Button>
</div>
@ -302,10 +302,14 @@ export default function SettingsPage() {
</div>
<div className='space-y-2'>
<Label>Export Your Calendar</Label>
<Button variant='outline' size='sm'>
<Button variant='outline_muted' size='sm'>
Get iCal Export URL
</Button>
<Button variant='outline' size='sm' className='ml-2'>
<Button
variant='outline_muted'
size='sm'
className='ml-2'
>
Download .ics File
</Button>
</div>
@ -398,7 +402,9 @@ export default function SettingsPage() {
</div>
<div className='space-y-2'>
<Label>Blocked Users</Label>
<Button variant='outline'>Manage Blocked Users</Button>
<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.

View file

@ -0,0 +1,16 @@
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';
export { default as logo_colored_secondary_dark } from '@/assets/logo/logo_colored_secondary_dark.svg';
export { default as logo_mono_combo_light } from '@/assets/logo/logo_mono_combo_light.svg';
export { default as logo_mono_combo_dark } from '@/assets/logo/logo_mono_combo_dark.svg';
export { default as logo_mono_primary_light } from '@/assets/logo/logo_mono_primary_light.svg';
export { default as logo_mono_primary_dark } from '@/assets/logo/logo_mono_primary_dark.svg';
export { default as logo_mono_secondary_light } from '@/assets/logo/logo_mono_secondary_light.svg';
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/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

@ -1,24 +1,91 @@
import NextAuth from 'next-auth';
import NextAuth, { CredentialsSignin } from 'next-auth';
import { Prisma } from '@/generated/prisma';
import type { Provider } from 'next-auth/providers';
import Credentials from 'next-auth/providers/credentials';
import Authentik from 'next-auth/providers/authentik';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { prisma } from '@/prisma';
import { loginSchema } from '@/lib/auth/validation';
import { ZodError } from 'zod/v4';
class InvalidLoginError extends CredentialsSignin {
constructor(code: string) {
super();
this.code = code;
this.message = code;
}
}
const providers: Provider[] = [
!process.env.DISABLE_PASSWORD_LOGIN &&
Credentials({
credentials: { password: { label: 'Password', type: 'password' } },
authorize(c) {
if (c.password !== 'password') return null;
return {
id: 'test',
name: 'Test User',
email: 'test@example.com',
};
async authorize(c) {
if (
process.env.NODE_ENV === 'development' &&
process.env.DISABLE_AUTH_TEST_USER !== 'true' &&
c.password === 'password'
)
return {
id: 'test',
name: 'Test User',
email: 'test@example.com',
};
if (process.env.DISABLE_PASSWORD_LOGIN) return null;
try {
const { email, password } = await loginSchema.parseAsync(c);
const user = await prisma.user.findFirst({
where: { OR: [{ email }, { name: email }] },
include: { accounts: true },
});
if (!user)
throw new InvalidLoginError(
'username/email or password is not correct',
);
if (user.accounts[0].provider !== 'credentials') {
throw new InvalidLoginError(
'username/email or password is not correct',
);
}
const passwordsMatch = await (
await import('bcryptjs')
).compare(password, user.password_hash!);
if (!passwordsMatch) {
throw new InvalidLoginError(
'username/email or password is not correct',
);
}
if (!user.emailVerified) {
throw new InvalidLoginError(
'Email not verified. Please check your inbox.',
);
}
return user;
} catch (error) {
if (
error instanceof Prisma?.PrismaClientInitializationError ||
error instanceof Prisma?.PrismaClientKnownRequestError
) {
throw new InvalidLoginError('System error. Please contact support');
}
if (error instanceof ZodError) {
throw new InvalidLoginError(error.issues[0].message);
}
throw error;
}
},
}),
process.env.AUTH_AUTHENTIK_ID && Authentik,
@ -49,5 +116,18 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
authorized({ auth }) {
return !!auth?.user;
},
session: async ({ session, token }) => {
if (session?.user) {
session.user.id = token.sub as string;
}
return session;
},
jwt: async ({ user, token }) => {
if (user) {
token.uid = user.id;
}
return token;
},
},
debug: process.env.NODE_ENV === 'development',
});

View file

@ -12,7 +12,7 @@ export function IconButton({
children: React.ReactNode;
} & React.ComponentProps<typeof Button>) {
return (
<Button type='button' variant='default' {...props}>
<Button type='button' variant='secondary' {...props}>
<FontAwesomeIcon icon={icon} className='mr-2' />
{children}
</Button>

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

@ -0,0 +1,32 @@
import { signIn } from '@/auth';
import { IconButton } from '@/components/buttons/icon-button';
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'
action={async () => {
'use server';
await signIn(provider);
}}
>
<IconButton
className='w-full'
type='submit'
variant='secondary'
icon={faOpenid}
{...props}
>
Login with {providerDisplayName}
</IconButton>
</form>
);
}

View file

@ -0,0 +1,131 @@
'use client';
import React from 'react';
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/custom-ui/sidebar';
import { ChevronDown } from 'lucide-react';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import Logo from '@/components/misc/logo';
import Link from 'next/link';
import {
Star,
CalendarDays,
User,
Users,
CalendarClock,
CalendarPlus,
} from 'lucide-react';
const items = [
{
title: 'Calendar',
url: '#',
icon: CalendarDays,
},
{
title: 'Friends',
url: '#',
icon: User,
},
{
title: 'Groups',
url: '#',
icon: Users,
},
{
title: 'Events',
url: '#',
icon: CalendarClock,
},
];
export function AppSidebar() {
return (
<>
<Sidebar collapsible='icon' variant='sidebar'>
<SidebarHeader className='overflow-hidden'>
<Logo
colorType='colored'
logoType='combo'
height={50}
className='group-data-[collapsible=icon]:hidden min-w-[203px]'
></Logo>
<Logo
colorType='colored'
logoType='submark'
height={50}
className='group-data-[collapsible=]:hidden group-data-[mobile=true]/mobile:hidden'
></Logo>
</SidebarHeader>
<SidebarContent className='grid grid-rows-[auto_1fr_auto]'>
<Collapsible defaultOpen className='group/collapsible'>
<SidebarGroup>
<SidebarGroupLabel asChild>
<CollapsibleTrigger>
<span className='flex items-center gap-2 text-xl font-label text-neutral-100'>
<Star className='size-8' />{' '}
<span className='group-data-[collapsible=icon]:hidden'>
Favorites
</span>
</span>
<ChevronDown className='ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180 group-data-[collapsible=icon]:hidden text-nowrap whitespace-nowrap' />
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent />
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title} className='pt-2'>
<SidebarMenuButton asChild>
<Link href={item.url}>
<item.icon className='size-8' />
<span className='text-xl font-label group-data-[collapsible=icon]:hidden text-nowrap whitespace-nowrap'>
{item.title}
</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
<SidebarFooter>
<SidebarMenuItem className='pl-[8px]'>
<Link
href='/event/new'
className='flex items-center gap-2 text-xl font-label'
>
<CalendarPlus className='size-8' />
<span className='group-data-[collapsible=icon]:hidden text-nowrap whitespace-nowrap'>
New Event
</span>
</Link>
</SidebarMenuItem>
</SidebarFooter>
</SidebarContent>
</Sidebar>
</>
);
}

View file

@ -7,16 +7,20 @@ export default function LabeledInput({
placeholder,
value,
name,
...props
autocomplete,
error,
...rest
}: {
type: 'text' | 'email' | 'password';
label: string;
placeholder?: string;
name: string;
value?: string;
name?: string;
autocomplete?: string;
error?: string;
} & React.InputHTMLAttributes<HTMLInputElement>) {
return (
<div className='flex flex-col gap-1'>
<div className='grid grid-cols-1 gap-1'>
<Label htmlFor={name}>{label}</Label>
<Input
@ -25,8 +29,10 @@ export default function LabeledInput({
defaultValue={value}
id={name}
name={name}
{...props}
autoComplete={autocomplete}
{...rest}
/>
{error && <p className='text-red-500 text-sm mt-1'>{error}</p>}
</div>
);
}

View file

@ -0,0 +1,118 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card'
className={cn(
/* Text */
'',
/* Background */
'bg-card',
/* Border */
'rounded-[11px]',
/* Font */
'',
/* Cursor */
'',
/* Ring */
'',
/* Outline */
'',
/* Shadow */
'shadow-[4px_4px_9px_9px_rgba(0,0,0,0.25)]',
/* Opacity */
'',
/* Scaling */
'',
/* Spacing */
'gap-6 py-6',
/* Alignment */
'flex flex-col',
/* Miscellaneous */
'',
/* ////////// */
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-header'
className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-title'
className={cn('leading-none font-semibold', className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-description'
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-action'
className={cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-content'
className={cn('px-6', className)}
{...props}
/>
);
}
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)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};

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,228 @@
'use client';
import React, { useState, useRef } from 'react';
import { useRouter } from 'next/navigation';
import LabeledInput from '@/components/custom-ui/labeled-input';
import { Button } from '@/components/ui/button';
import useZodForm from '@/lib/hooks/useZodForm';
import { loginSchema, registerSchema } from '@/lib/auth/validation';
import { loginAction } from '@/lib/auth/login';
import { registerAction } from '@/lib/auth/register';
function LoginFormElement({
setIsSignUp,
formRef,
}: {
setIsSignUp: (value: boolean | ((prev: boolean) => boolean)) => void;
formRef?: React.RefObject<HTMLFormElement | null>;
}) {
const { handleSubmit, formState, register, setError } =
useZodForm(loginSchema);
const router = useRouter();
const onSubmit = handleSubmit(async (data) => {
try {
const { error } = await loginAction(data);
if (error) {
setError('root', {
message: error,
});
return;
} else {
router.push('/home');
router.refresh();
return;
}
} catch (error: unknown) {
if (error instanceof Error)
setError('root', {
message: error?.message,
});
else
setError('root', {
message: 'An unknown error occurred.',
});
}
});
return (
<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'
label='Password'
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' data-cy='login-button'>
Login
</Button>
<Button
type='button'
variant='outline_primary'
onClick={() => {
formRef?.current?.reset();
setIsSignUp((v) => !v);
}}
data-cy='register-switch'
>
Sign Up
</Button>
</div>
<div>
{formState.errors.root?.message && (
<p className='text-red-500'>{formState.errors.root?.message}</p>
)}
</div>
</form>
);
}
function RegisterFormElement({
setIsSignUp,
formRef,
}: {
setIsSignUp: (value: boolean | ((prev: boolean) => boolean)) => void;
formRef?: React.RefObject<HTMLFormElement | null>;
}) {
const { handleSubmit, formState, register, setError } =
useZodForm(registerSchema);
const onSubmit = handleSubmit(async (data) => {
try {
const { error } = await registerAction(data);
if (error) {
setError('root', {
message: error,
});
return;
} else {
formRef?.current?.reset();
setIsSignUp(false);
// TODO: Show registration success message (reminder to verify email)
return;
}
} catch (error: unknown) {
if (error instanceof Error)
setError('root', {
message: error?.message,
});
else
setError('root', {
message: 'An unknown error occurred.',
});
}
});
return (
<form
ref={formRef}
className='flex flex-col gap-5 w-full'
onSubmit={onSubmit}
data-cy='register-form'
>
<LabeledInput
type='text'
label='First Name'
placeholder='Your first name'
autocomplete='given-name'
error={formState.errors.firstName?.message}
{...register('firstName')}
data-cy='first-name-input'
/>
<LabeledInput
type='text'
label='Last Name'
placeholder='Your last name'
autocomplete='family-name'
error={formState.errors.lastName?.message}
{...register('lastName')}
data-cy='last-name-input'
/>
<LabeledInput
type='email'
label='E-Mail'
placeholder='Your email address'
autocomplete='email'
error={formState.errors.email?.message}
{...register('email')}
data-cy='email-input'
/>
<LabeledInput
type='text'
label='Username'
placeholder='Your username'
autocomplete='username'
error={formState.errors.username?.message}
{...register('username')}
data-cy='username-input'
/>
<LabeledInput
type='password'
label='Password'
placeholder='Create a password'
autocomplete='new-password'
error={formState.errors.password?.message}
{...register('password')}
data-cy='password-input'
/>
<LabeledInput
type='password'
label='Confirm Password'
placeholder='Repeat your password'
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' data-cy='register-button'>
Sign Up
</Button>
<Button
type='button'
variant='outline_primary'
onClick={() => {
formRef?.current?.reset();
setIsSignUp((v) => !v);
}}
>
Back to Login
</Button>
</div>
<div>
{formState.errors.root?.message && (
<p className='text-red-500'>{formState.errors.root?.message}</p>
)}
</div>
</form>
);
}
export default function LoginForm() {
const [isSignUp, setIsSignUp] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
if (isSignUp) {
return <RegisterFormElement setIsSignUp={setIsSignUp} formRef={formRef} />;
}
return <LoginFormElement setIsSignUp={setIsSignUp} formRef={formRef} />;
}

View file

@ -1,24 +0,0 @@
/* eslint-disable @typescript-eslint/no-unused-expressions */
import React from 'react';
import { IconButton } from './icon-button';
import { faOpenid } from '@fortawesome/free-brands-svg-icons';
describe('<IconButton />', () => {
it('renders', () => {
cy.mount(<IconButton icon={faOpenid}>Button</IconButton>);
});
it('is clickable', () => {
const onClick = cy.stub();
cy.mount(
<IconButton icon={faOpenid} onClick={onClick} data-cy='icon-button'>
Button
</IconButton>,
);
cy.getBySel('icon-button')
.click()
.then(() => {
expect(onClick).to.be.calledOnce;
});
});
});

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>{children}</main>
</div>
);
}

View file

@ -0,0 +1,102 @@
'use client';
import React, { useEffect, useState } from 'react';
import Image, { ImageProps } from 'next/image';
import * as logoAssets from '@/assets/logo/logo-export';
import { useTheme } from 'next-themes';
type ColorType = 'colored' | 'monochrome';
type LogoType = 'combo' | 'primary' | 'secondary' | 'submark';
type Theme = 'light' | 'dark';
interface LogoProps extends Omit<ImageProps, 'src' | 'alt'> {
colorType: ColorType;
logoType: LogoType;
overrideTheme?: Theme;
alt?: string;
}
const LOGO_BASE_PATH = '/assets/logo/';
const IMAGE_EXTENSION = 'svg';
export default function Logo({
colorType,
logoType,
overrideTheme,
alt,
className = '',
width,
height,
// onError,
...imageProps
}: LogoProps) {
const [mounted, setMounted] = useState(false);
let { resolvedTheme: theme } = useTheme() as {
resolvedTheme?: Theme;
};
useEffect(() => {
setMounted(true);
}, []);
if (overrideTheme) {
theme = overrideTheme;
}
// Prevent rendering until mounted (theme is available)
if (!mounted && !overrideTheme) {
return null;
}
if (!colorType || !logoType || !theme) {
const errorMessage =
'Logo: colorType, logoType, and theme props are required.';
console.error(errorMessage);
return (
<div
role='alert'
className='p-2 text-red-700 bg-red-100 border border-red-500 rounded-md text-xs'
>
Error: Missing required logo props. Check console.
</div>
);
}
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}`,
);
}
const colorTypeInFilename = colorType === 'monochrome' ? 'mono' : colorType;
const defaultAltText = `Logo: ${colorType} ${logoType} ${theme}`;
const varName = `logo_${colorTypeInFilename}_${logoType}_${theme}` as const;
// Match the varName with the Logo-Asset name and store it in "logoVar"
const logoVar = logoAssets[varName];
if (!logoVar) {
console.error(`Logo: Could not find logo asset for ${varName}`);
return (
<div
role='alert'
className='p-2 text-red-700 bg-red-100 border border-red-500 rounded-md text-xs'
>
Error: Logo asset not found. Check console.
</div>
);
}
return (
<Image
unoptimized
src={logoVar}
alt={alt || defaultAltText}
className={className}
width={width}
height={height}
{...imageProps}
/>
);
}

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

@ -1,6 +1,6 @@
import React from 'react';
import { ThemePicker } from '@/components/user/theme-picker';
import { ThemeProvider } from '../theme-provider';
import { ThemePicker } from '@/components/misc/theme-picker';
import { ThemeProvider } from '@/components/wrappers/theme-provider';
describe('<ThemePicker />', () => {
it('renders', () => {

View file

@ -18,7 +18,7 @@ export function ThemePicker() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='outline' size='icon' data-cy='theme-picker'>
<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>

View file

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

View file

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

View file

@ -0,0 +1,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,31 +5,34 @@ import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all 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-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",
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
primary:
'bg-primary text-text shadow-xs hover:bg-hover-primary active:bg-active-primary disabled:bg-disabled-primary',
secondary:
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
'bg-secondary text-text-alt shadow-xs hover:bg-hover-secondary active:bg-active-secondary disabled:bg-disabled-secondary',
muted:
'bg-muted text-text shadow-xs hover:bg-hover-muted active:bg-active-muted disabled:bg-disabled-muted',
outline_primary:
'bg-background border-2 text-text shadow-xs hover:bg-primary border-primary hover:border-background-reversed active:bg-active-primary disabled:bg-disabled-primary',
outline_secondary:
'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',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
sm: 'h-8 gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 px-6 has-[>svg]:px-4',
icon: 'size-9',
},
},
defaultVariants: {
variant: 'default',
variant: 'primary',
size: 'default',
},
},
@ -50,7 +53,7 @@ function Button({
return (
<Comp
data-slot='button'
className={cn(buttonVariants({ variant, size, className }))}
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
);

View file

@ -7,7 +7,31 @@ function Card({ className, ...props }: React.ComponentProps<'div'>) {
<div
data-slot='card'
className={cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
/* Text */
'text-card-foreground',
/* Background */
'bg-card',
/* Border */
'border rounded-xl',
/* Font */
'',
/* Cursor */
'',
/* Ring */
'',
/* Outline */
'',
/* Shadow */
'shadow-sm',
/* Opacity */
'',
/* Scaling */
'',
/* Spacing */
'py-6 gap-6',
/* Alignment */
'flex flex-col',
/* ////////// */
className,
)}
{...props}
@ -20,7 +44,34 @@ function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
<div
data-slot='card-header'
className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
/* Text */
'',
/* Background */
'',
/* Border */
'',
/* Font */
'',
/* Cursor */
'',
/* Ring */
'',
/* Outline */
'',
/* Shadow */
'',
/* Opacity */
'',
/* Scaling */
'',
/* Spacing */
'gap-1.5 px-6 [.border-b]:pb-6',
/* Alignment */
'grid auto-rows-min grid-rows-[auto_auto]',
'items-start has-data-[slot=card-action]:grid-cols-[1fr_auto]',
/* Miscellanneous */
'@container/card-header',
/* ////////// */
className,
)}
{...props}

Some files were not shown because too many files have changed in this diff Show more