Compare commits
143 commits
5c3b6289ac
...
4c6e66c1a2
Author | SHA1 | Date | |
---|---|---|---|
4c6e66c1a2 | |||
3a4695bc03 | |||
8bee6ede3f | |||
1d9ab84047 | |||
9225d8435a | |||
a6f74e0c22 | |||
21eff651e8 | |||
d62e954348 | |||
2889424bfb | |||
3ee0dcf950 | |||
c98a72f2f1 | |||
29f2a01ac6 | |||
4cf5ce26ff | |||
16b878a2e9 | |||
280fa57e45 | |||
be1502a4ac | |||
f240bf525d | |||
525b8597f2 | |||
0da8e35b9b | |||
cd1ad5dbc4 | |||
5fbd7ac091 | |||
5e6feb39eb | |||
b652499788 | |||
96ff00f120 | |||
58cf178968 | |||
360f0788dd | |||
445a15ccc7 | |||
40d13101a3 | |||
68cafccec7 | |||
f5a5704be3 | |||
50d915854f | |||
b10b374b84 | |||
c71de4a14c | |||
eb04c276ab | |||
3e890d4363 | |||
87dc6162f4 | |||
98776aacb2 | |||
a412d0710b | |||
050a1d2bf5 | |||
138970f4c3 | |||
77653bcc69 | |||
c49c654f9f | |||
34a2956399 | |||
d054fe1079 | |||
8d3aa9ec85 | |||
882464e6cb | |||
d769cdcd5c | |||
cfe73652e2 | |||
b7d443e7a1 | |||
378b88dbdb | |||
c2861047d0 | |||
4e87c11ec3 | |||
210bd132cc | |||
0e0c89fdd7 | |||
de9216807c | |||
dfbc092a7b | |||
3f99449f2f | |||
de2e1c22ff | |||
72a5c25838 | |||
171f0ae099 | |||
a351a9017d | |||
15fbf27459 | |||
9183117a20 | |||
386d72d914 | |||
6c479e80d6 | |||
abae5c74d5 | |||
3569ccc18e | |||
d4de7876cc | |||
0c93778c5a | |||
4b80c89050 | |||
16cde64761 | |||
a2a5eee49e | |||
cdc0e81e51 | |||
c732b3138e | |||
24110a733d | |||
6cf0b59a9e | |||
a17cf65452 | |||
f0eb05c50d | |||
7cefd39652 | |||
327b984974 | |||
573c3053b6 | |||
9cb378f197 | |||
01c77101f9 | |||
49b3869d7b | |||
f0e23139a2 | |||
d4d4421f36 | |||
b309688372 | |||
9acb71d050 | |||
772c8f84e8 | |||
6350208c4d | |||
4b3e0677e4 | |||
a1a482abcc | |||
4f4e73318a | |||
9f9c2157f5 | |||
b5d1086131 | |||
f3b5f5c87c | |||
198520ff83 | |||
89cc743fda | |||
af0570c2d7 | |||
effa57a501 | |||
e897c55c68 | |||
983951beed | |||
195332c46f | |||
88faae0fc9 | |||
6c1c4acd6e | |||
5e5b58d33c | |||
de596ec022 | |||
974de82d5d | |||
287a082bf2 | |||
4decff19c3 | |||
c32b198340 | |||
ca3ea0e8f2 | |||
7ad191467e | |||
ea0030f56c | |||
6534801ad2 | |||
e276f3b511 | |||
5c829b226b | |||
1ac8d7ea8f | |||
efa56e2d3c | |||
bc11b56b85 | |||
821d529193 | |||
0a2d3515c9 | |||
3cf3663469 | |||
b28e4e40c9 | |||
9630e590fe | |||
226c257b6f | |||
e298aec60e | |||
df7413d6ba | |||
89b73865ec | |||
76f75f0b57 | |||
5bcb16a9b5 | |||
784549f7e1 | |||
08a83ba3c5 | |||
c2a074f734 | |||
e5359ea4d3 | |||
42ca2e1df9 | |||
5315b6b81f | |||
b2513609df | |||
48d5b45e21 | |||
cdcad254cf | |||
96809db26f | |||
88dc6303c1 | |||
590b7c5696 |
|
@ -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"
|
7
.github/workflows/container-scan.yml
vendored
|
@ -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
|
||||
|
|
14
.github/workflows/docker-build.yml
vendored
|
@ -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
|
@ -44,7 +44,7 @@ next-env.d.ts
|
|||
|
||||
# database
|
||||
/prisma/*.db*
|
||||
src/generated/prisma
|
||||
src/generated/*
|
||||
data
|
||||
|
||||
# cypress
|
||||
|
|
4
.vscode/extensions.json
vendored
|
@ -4,6 +4,8 @@
|
|||
"vivaxy.vscode-conventional-commits",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"bourhaouta.tailwindshades"
|
||||
"bourhaouta.tailwindshades",
|
||||
"nize.oklch-preview",
|
||||
"azizziy.oklch-as"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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
|
@ -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"]
|
18
README.md
|
@ -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
|
@ -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');
|
||||
}
|
9
cypress/e2e/event-create.cy.ts
Normal 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');
|
||||
});
|
||||
});
|
|
@ -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
|
@ -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();
|
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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',
|
||||
},
|
||||
},
|
||||
};
|
67
package.json
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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
After Width: | Height: | Size: 3.4 KiB |
BIN
public/favicon-dark.png
Normal file
After Width: | Height: | Size: 1.6 KiB |
10
public/favicon-dark.svg
Normal 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
After Width: | Height: | Size: 1.7 KiB |
10
public/favicon-light.svg
Normal 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
After Width: | Height: | Size: 15 KiB |
BIN
public/fonts/Comfortaa/Comfortaa-VariableFont_weight.ttf
Normal file
93
public/fonts/Comfortaa/OFL.txt
Normal 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.
|
67
public/fonts/Comfortaa/README.txt
Normal 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 aren’t 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.
|
BIN
public/fonts/Comfortaa/static/Comfortaa-Bold.ttf
Normal file
BIN
public/fonts/Comfortaa/static/Comfortaa-Light.ttf
Normal file
BIN
public/fonts/Comfortaa/static/Comfortaa-Medium.ttf
Normal file
BIN
public/fonts/Comfortaa/static/Comfortaa-Regular.ttf
Normal file
BIN
public/fonts/Comfortaa/static/Comfortaa-SemiBold.ttf
Normal file
93
public/fonts/VarelaRound/OFL.txt
Normal 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.
|
BIN
public/fonts/VarelaRound/VarelaRound-Regular.ttf
Normal file
21
public/site.webmanifest
Normal 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"
|
||||
}
|
BIN
public/web-app-manifest-192x192.png
Normal file
After Width: | Height: | Size: 3.7 KiB |
BIN
public/web-app-manifest-512x512.png
Normal file
After Width: | Height: | Size: 14 KiB |
21
src/app/(main)/home/page.tsx
Normal 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
|
@ -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
|
@ -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>
|
||||
);
|
||||
}
|
14
src/app/api-doc/react-swagger.tsx
Normal 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;
|
276
src/app/api/event/[eventID]/participant/[user]/route.ts
Normal 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,
|
||||
});
|
||||
});
|
102
src/app/api/event/[eventID]/participant/[user]/swagger.ts
Normal 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'],
|
||||
});
|
||||
}
|
200
src/app/api/event/[eventID]/participant/route.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
});
|
72
src/app/api/event/[eventID]/participant/swagger.ts
Normal 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'],
|
||||
});
|
||||
}
|
50
src/app/api/event/[eventID]/participant/validation.ts
Normal 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),
|
||||
});
|
318
src/app/api/event/[eventID]/route.ts
Normal 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 },
|
||||
);
|
||||
});
|
94
src/app/api/event/[eventID]/swagger.ts
Normal 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
|
@ -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 },
|
||||
);
|
||||
});
|
62
src/app/api/event/swagger.ts
Normal 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'],
|
||||
});
|
||||
}
|
167
src/app/api/event/validation.ts
Normal 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),
|
||||
});
|
79
src/app/api/search/user/route.ts
Normal 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 },
|
||||
);
|
||||
});
|
33
src/app/api/search/user/swagger.ts
Normal 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'],
|
||||
});
|
||||
}
|
20
src/app/api/search/user/validation.ts
Normal 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(),
|
||||
});
|
212
src/app/api/user/[user]/calendar/route.ts
Normal 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,
|
||||
});
|
||||
});
|
37
src/app/api/user/[user]/calendar/swagger.ts
Normal 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'],
|
||||
});
|
||||
}
|
99
src/app/api/user/[user]/calendar/validation.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
});
|
55
src/app/api/user/[user]/route.ts
Normal 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 },
|
||||
);
|
||||
});
|
33
src/app/api/user/[user]/swagger.ts
Normal 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'],
|
||||
});
|
||||
}
|
122
src/app/api/user/me/password/route.ts
Normal 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,
|
||||
});
|
||||
});
|
43
src/app/api/user/me/password/swagger.ts
Normal 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'],
|
||||
});
|
||||
}
|
160
src/app/api/user/me/route.ts
Normal 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 },
|
||||
);
|
||||
});
|
84
src/app/api/user/me/swagger.ts
Normal 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'],
|
||||
});
|
||||
}
|
33
src/app/api/user/me/validation.ts
Normal 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',
|
||||
});
|
162
src/app/api/user/validation.ts
Normal 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
|
@ -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',
|
||||
}),
|
||||
);
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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,9 +55,8 @@ export default async function LoginPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<HoverCard>
|
||||
<HoverCardTrigger className='text-sm text-muted-foreground hover:underline'>
|
||||
<HoverCardTrigger>
|
||||
<Button variant='link'>made with love</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className='flex items-center justify-center'>
|
||||
|
@ -65,5 +69,6 @@ export default async function LoginPage() {
|
|||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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.
|
||||
|
|
16
src/assets/logo/logo-export.ts
Normal 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';
|
17
src/assets/logo/new/logo_colored_combo_dark.svg
Normal 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 |
17
src/assets/logo/new/logo_colored_combo_light.svg
Normal 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 |
10
src/assets/logo/new/logo_colored_submark_dark.svg
Normal 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 |
10
src/assets/logo/new/logo_colored_submark_light.svg
Normal 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 |
88
src/auth.ts
|
@ -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;
|
||||
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',
|
||||
});
|
||||
|
|
|
@ -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>
|
31
src/components/buttons/notification-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
32
src/components/buttons/sso-login-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
131
src/components/custom-ui/app-sidebar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
118
src/components/custom-ui/login-card.tsx
Normal 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,
|
||||
};
|
725
src/components/custom-ui/sidebar.tsx
Normal 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,
|
||||
};
|
228
src/components/forms/login-form.tsx
Normal 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} />;
|
||||
}
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
});
|
51
src/components/misc/header.tsx
Normal 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>
|
||||
);
|
||||
}
|
102
src/components/misc/logo.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
110
src/components/misc/nav-user.tsx
Normal 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>
|
||||
);
|
||||
}
|
35
src/components/misc/notification-dot.tsx
Normal 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 };
|
|
@ -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', () => {
|
|
@ -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>
|
29
src/components/misc/user-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
52
src/components/misc/user-dropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
53
src/components/ui/avatar.tsx
Normal 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 };
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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}
|
||||
|
|