diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 380370f..e8cecd6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -46,6 +46,7 @@ jobs: env: USOS_CONSUMER_KEY: hello USOS_CONSUMER_SECRET: jello + NEXT_PUBLIC_API_URL: http://localhost:3333 if: always() - name: Find deadcode diff --git a/backend/app/controllers/schedules_controller.ts b/backend/app/controllers/schedules_controller.ts index 748e2bb..5a08ba0 100644 --- a/backend/app/controllers/schedules_controller.ts +++ b/backend/app/controllers/schedules_controller.ts @@ -78,6 +78,18 @@ export default class SchedulesController { userId: userId, }) + if (payload.groups !== undefined) { + await schedule.related('groups').sync(payload.groups.map((group) => group.id)) + } + + if (payload.registrations !== undefined) { + await schedule.related('registrations').sync(payload.registrations.map((group) => group.id)) + } + + if (payload.courses !== undefined) { + await schedule.related('courses').sync(payload.courses.map((group) => group.id)) + } + return { message: 'Schedule created.', schedule } } @@ -112,6 +124,10 @@ export default class SchedulesController { // Transformacja danych do żądanej struktury const transformedSchedule = { + id: schedule.id, + userId: schedule.userId, + createdAt: schedule.createdAt, + updatedAt: schedule.updatedAt, name: schedule.name, registrations: schedule.registrations.map((reg) => ({ id: reg.id, @@ -136,55 +152,57 @@ export default class SchedulesController { * Allows updating the schedule and modifying its groups */ async update({ params, request, auth }: HttpContext) { - const userId = auth.user?.id - if (!userId) { - return { message: 'User not authenticated.' } - } + try { + const userId = auth.user?.id + if (!userId) { + return { message: 'User not authenticated.' } + } - const payload = await request.validateUsing(updateScheduleValidator) + const payload = await request.validateUsing(updateScheduleValidator) - const currSchedule = await Schedule.query() - .where('id', params.schedule_id) - .andWhere('userId', userId) - .firstOrFail() + const currSchedule = await Schedule.query() + .where('id', params.schedule_id) + .andWhere('userId', userId) + .firstOrFail() - if (payload.name) { - currSchedule.name = payload.name - } + if (payload.name) { + currSchedule.name = payload.name + } - if (payload.groups !== undefined) { - if (payload.groups.length === 0) { - await currSchedule.related('groups').sync([]) - } else { - await currSchedule.related('groups').sync(payload.groups.map((group) => group.id)) + if (payload.groups !== undefined) { + if (payload.groups.length === 0) { + await currSchedule.related('groups').sync([]) + } else { + await currSchedule.related('groups').sync(payload.groups.map((group) => group.id)) + } } - } - if (payload.registrations !== undefined) { - if (payload.registrations.length === 0) { - await currSchedule.related('registrations').sync([]) - } else { - await currSchedule - .related('registrations') - .sync(payload.registrations.map((group) => group.id)) + if (payload.registrations !== undefined) { + if (payload.registrations.length === 0) { + await currSchedule.related('registrations').sync([]) + } else { + await currSchedule + .related('registrations') + .sync(payload.registrations.map((group) => group.id)) + } } - } - if (payload.courses !== undefined) { - if (payload.courses.length === 0) { - await currSchedule.related('courses').sync([]) - } else { - await currSchedule.related('courses').sync(payload.courses.map((group) => group.id)) + if (payload.courses !== undefined) { + if (payload.courses.length === 0) { + await currSchedule.related('courses').sync([]) + } else { + await currSchedule.related('courses').sync(payload.courses.map((group) => group.id)) + } } - } - if (payload.updatedAt) { - currSchedule.updatedAt = DateTime.fromJSDate(payload.updatedAt) - } + currSchedule.updatedAt = DateTime.fromISO(new Date().toISOString()) - await currSchedule.save() + await currSchedule.save() - return { message: 'Schedule updated successfully.', currSchedule } + return { message: 'Schedule updated successfully.', schedule: currSchedule, success: true } + } catch (error) { + return { message: 'Schedule not found.', success: false } + } } /** diff --git a/backend/app/validators/schedule.ts b/backend/app/validators/schedule.ts index 7aaef34..e323868 100644 --- a/backend/app/validators/schedule.ts +++ b/backend/app/validators/schedule.ts @@ -3,6 +3,27 @@ import vine from '@vinejs/vine' export const createScheduleValidator = vine.compile( vine.object({ name: vine.string(), + groups: vine + .array( + vine.object({ + id: vine.number(), + }) + ) + .optional(), + courses: vine + .array( + vine.object({ + id: vine.string(), + }) + ) + .optional(), + registrations: vine + .array( + vine.object({ + id: vine.string(), + }) + ) + .optional(), }) ) @@ -30,6 +51,6 @@ export const updateScheduleValidator = vine.compile( }) ) .optional(), - updatedAt: vine.date().optional(), + updatedAt: vine.string().optional(), }) ) diff --git a/frontend/knip.ts b/frontend/knip.ts index 2f0f2ff..da612fc 100644 --- a/frontend/knip.ts +++ b/frontend/knip.ts @@ -9,7 +9,7 @@ const config = { "lint-staged.config.mjs", ], // sharp is used in nextjs image optimization - ignoreDependencies: ["sharp", "@radix-ui/*", "eslint-config-next"], + ignoreDependencies: ["sharp", "@radix-ui/*"], } satisfies KnipConfig; export default config; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7f5367b..f06b604 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,6 +24,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.0", + "crypto-js": "^4.2.0", "date-fns": "^4.1.0", "fetch-cookie": "^3.0.1", "framer-motion": "^11.3.28", @@ -32,12 +33,14 @@ "lucide-react": "^0.426.0", "next": "^15.0.3", "next-sitemap": "^4.2.3", + "next-themes": "^0.4.3", "node-fetch": "^3.3.2", "oauth-1.0a": "^2.2.6", "react": "^18.3.1", "react-dom": "^18.3.1", "react-icons": "^5.3.0", "sharp": "^0.33.5", + "sonner": "^1.7.0", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", "vaul": "^0.9.2", @@ -47,6 +50,7 @@ "@alergeek-ventures/eslint-config": "^9.0.17", "@next/eslint-plugin-next": "^14.2.7", "@trivago/prettier-plugin-sort-imports": "^4.3.0", + "@types/crypto-js": "^4.2.2", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", @@ -2025,28 +2029,28 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.7.tgz", - "integrity": "sha512-yDzVT/Lm101nQ5TCVeK65LtdN7Tj4Qpr9RTXJ2vPFLqtLxwOrpoxAHAJI8J3yYWUc40J0BDBheaitK5SJmno2g==", + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz", + "integrity": "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==", "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.7" + "@floating-ui/utils": "^0.2.8" } }, "node_modules/@floating-ui/dom": { - "version": "1.6.10", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.10.tgz", - "integrity": "sha512-fskgCFv8J8OamCmyun8MfjB1Olfn+uZKjOKZ0vhYF3gRmEUXcGOjxWL8bBr7i4kIuPZ2KD2S3EUIOxnjC8kl2A==", + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.12.tgz", + "integrity": "sha512-NP83c0HjokcGVEMeoStg317VD9W7eDlGK7457dMBANbKA6GJZdc7rjujdgqzTaz93jkGgc5P/jeWbaCHnMNc+w==", "license": "MIT", "dependencies": { "@floating-ui/core": "^1.6.0", - "@floating-ui/utils": "^0.2.7" + "@floating-ui/utils": "^0.2.8" } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.1.tgz", - "integrity": "sha512-4h84MJt3CHrtG18mGsXuLCHMrug49d7DFkU0RMIyshRveBeyV2hmV/pDaF2Uxtu8kgq5r46llp5E5FQiR0K2Yg==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", + "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", "license": "MIT", "dependencies": { "@floating-ui/dom": "^1.0.0" @@ -2057,9 +2061,9 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.7.tgz", - "integrity": "sha512-X8R8Oj771YRl/w+c1HqAC1szL8zWQRwFvgDwT129k9ACdBoud/+/rX9V0qiMl6LWUdP9voC2nDVZYPMQQsb6eA==", + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", + "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==", "license": "MIT" }, "node_modules/@humanwhocodes/config-array": { @@ -3011,25 +3015,79 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.1.tgz", - "integrity": "sha512-zysS+iU4YP3STKNS6USvFVqI4qqx8EpiwmT5TuCApVEBca+eRCbONi4EgzfNSuVnOXvC5UPHHMjs8RXO6DH9Bg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.2.tgz", + "integrity": "sha512-Yj4dZtqa2o+kG61fzB0H2qUvmwBA2oyQroGLyNtBj1beo1khoQ3q1a2AO8rrQYjd8256CO9+N8L9tvsS+bnIyA==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.0", "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-dismissable-layer": "1.1.0", - "@radix-ui/react-focus-guards": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-focus-guards": "1.1.1", "@radix-ui/react-focus-scope": "1.1.0", "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-portal": "1.1.1", - "@radix-ui/react-presence": "1.1.0", + "@radix-ui/react-portal": "1.1.2", + "@radix-ui/react-presence": "1.1.1", "@radix-ui/react-primitive": "2.0.0", "@radix-ui/react-slot": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0", "aria-hidden": "^1.1.1", - "react-remove-scroll": "2.5.7" + "react-remove-scroll": "2.6.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", + "integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.1.tgz", + "integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", @@ -3062,9 +3120,9 @@ } }, "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.0.tgz", - "integrity": "sha512-/UovfmmXGptwGcBQawLzvn2jOfM0t4z3/uKffoBlj724+n3FvBbZ7M0aaBOmkp6pqFYpO4yx8tSVJjx3Fl2jig==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.1.tgz", + "integrity": "sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.0", @@ -3277,17 +3335,29 @@ } } }, - "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-dismissable-layer": { + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-focus-guards": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.1.tgz", - "integrity": "sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", + "integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-presence": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.1.tgz", + "integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.0", "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-escape-keydown": "1.1.0" + "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", @@ -3304,29 +3374,54 @@ } } }, - "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-focus-guards": { + "node_modules/@radix-ui/react-popover": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", - "integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.1.tgz", + "integrity": "sha512-3y1A3isulwnWhvTTwmIreiB8CF4L+qRjZnK1wYLO7pplddzXKby/GnZ2M7OZY3qgnl6p9AodUIHRYGXNah8Y7g==", "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.0", + "@radix-ui/react-focus-guards": "1.1.0", + "@radix-ui/react-focus-scope": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.0", + "@radix-ui/react-portal": "1.1.1", + "@radix-ui/react-presence": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.5.7" + }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-portal": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.2.tgz", - "integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==", + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.0.tgz", + "integrity": "sha512-/UovfmmXGptwGcBQawLzvn2jOfM0t4z3/uKffoBlj724+n3FvBbZ7M0aaBOmkp6pqFYpO4yx8tSVJjx3Fl2jig==", "license": "MIT", "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-layout-effect": "1.1.0" + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" }, "peerDependencies": { "@types/react": "*", @@ -3343,13 +3438,13 @@ } } }, - "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-presence": { + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-portal": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.1.tgz", - "integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.1.tgz", + "integrity": "sha512-A3UtLk85UtqhzFqtoC8Q0KvR2GbXF3mtPgACSazajqq6A41mEQgo53iPzY4i6BwDxlIFqWIhiQ2G729n+2aw/g==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { @@ -3367,13 +3462,13 @@ } } }, - "node_modules/@radix-ui/react-menu/node_modules/react-remove-scroll": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.0.tgz", - "integrity": "sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==", + "node_modules/@radix-ui/react-popover/node_modules/react-remove-scroll": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.7.tgz", + "integrity": "sha512-FnrTWO4L7/Bhhf3CYBNArEG/yROV0tKmTv7/3h9QCFvH6sndeFf1wPqOcbFVu5VAulS5dV1wGT3GZZ/1GawqiA==", "license": "MIT", "dependencies": { - "react-remove-scroll-bar": "^2.3.6", + "react-remove-scroll-bar": "^2.3.4", "react-style-singleton": "^2.2.1", "tslib": "^2.1.0", "use-callback-ref": "^1.3.0", @@ -3392,43 +3487,6 @@ } } }, - "node_modules/@radix-ui/react-popover": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.1.tgz", - "integrity": "sha512-3y1A3isulwnWhvTTwmIreiB8CF4L+qRjZnK1wYLO7pplddzXKby/GnZ2M7OZY3qgnl6p9AodUIHRYGXNah8Y7g==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-dismissable-layer": "1.1.0", - "@radix-ui/react-focus-guards": "1.1.0", - "@radix-ui/react-focus-scope": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-popper": "1.2.0", - "@radix-ui/react-portal": "1.1.1", - "@radix-ui/react-presence": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "2.5.7" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-popper": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz", @@ -3462,9 +3520,9 @@ } }, "node_modules/@radix-ui/react-portal": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.1.tgz", - "integrity": "sha512-A3UtLk85UtqhzFqtoC8Q0KvR2GbXF3mtPgACSazajqq6A41mEQgo53iPzY4i6BwDxlIFqWIhiQ2G729n+2aw/g==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.2.tgz", + "integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==", "license": "MIT", "dependencies": { "@radix-ui/react-primitive": "2.0.0", @@ -3606,6 +3664,82 @@ } } }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.0.tgz", + "integrity": "sha512-/UovfmmXGptwGcBQawLzvn2jOfM0t4z3/uKffoBlj724+n3FvBbZ7M0aaBOmkp6pqFYpO4yx8tSVJjx3Fl2jig==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-portal": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.1.tgz", + "integrity": "sha512-A3UtLk85UtqhzFqtoC8Q0KvR2GbXF3mtPgACSazajqq6A41mEQgo53iPzY4i6BwDxlIFqWIhiQ2G729n+2aw/g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/react-remove-scroll": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.7.tgz", + "integrity": "sha512-FnrTWO4L7/Bhhf3CYBNArEG/yROV0tKmTv7/3h9QCFvH6sndeFf1wPqOcbFVu5VAulS5dV1wGT3GZZ/1GawqiA==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.4", + "react-style-singleton": "^2.2.1", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.0", + "use-sidecar": "^1.1.2" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", @@ -4202,6 +4336,13 @@ "node": ">=4" } }, + "node_modules/@types/crypto-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", + "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", @@ -5528,24 +5669,6 @@ } } }, - "node_modules/cmdk/node_modules/@radix-ui/react-context": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.1.tgz", - "integrity": "sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/cmdk/node_modules/@radix-ui/react-dialog": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz", @@ -5583,7 +5706,25 @@ } } }, - "node_modules/cmdk/node_modules/@radix-ui/react-dismissable-layer": { + "node_modules/cmdk/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.1.tgz", + "integrity": "sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz", "integrity": "sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==", @@ -5611,7 +5752,44 @@ } } }, - "node_modules/cmdk/node_modules/@radix-ui/react-focus-guards": { + "node_modules/cmdk/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", + "integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz", + "integrity": "sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-guards": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz", "integrity": "sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==", @@ -5629,7 +5807,7 @@ } } }, - "node_modules/cmdk/node_modules/@radix-ui/react-focus-scope": { + "node_modules/cmdk/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-scope": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.4.tgz", "integrity": "sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==", @@ -5655,7 +5833,25 @@ } } }, - "node_modules/cmdk/node_modules/@radix-ui/react-id": { + "node_modules/cmdk/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", + "integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-id": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.1.tgz", "integrity": "sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==", @@ -5674,7 +5870,25 @@ } } }, - "node_modules/cmdk/node_modules/@radix-ui/react-portal": { + "node_modules/cmdk/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-id/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz", + "integrity": "sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-portal": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.0.4.tgz", "integrity": "sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==", @@ -5698,7 +5912,7 @@ } } }, - "node_modules/cmdk/node_modules/@radix-ui/react-presence": { + "node_modules/cmdk/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.1.tgz", "integrity": "sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==", @@ -5723,38 +5937,32 @@ } } }, - "node_modules/cmdk/node_modules/@radix-ui/react-primitive": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz", - "integrity": "sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==", + "node_modules/cmdk/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz", + "integrity": "sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "1.0.2" + "@babel/runtime": "^7.13.10" }, "peerDependencies": { "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" + "react": "^16.8 || ^17.0 || ^18.0" }, "peerDependenciesMeta": { "@types/react": { "optional": true - }, - "@types/react-dom": { - "optional": true } } }, - "node_modules/cmdk/node_modules/@radix-ui/react-slot": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", - "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", + "node_modules/cmdk/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz", + "integrity": "sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "1.0.1" + "@radix-ui/react-use-callback-ref": "1.0.1" }, "peerDependencies": { "@types/react": "*", @@ -5766,7 +5974,7 @@ } } }, - "node_modules/cmdk/node_modules/@radix-ui/react-use-callback-ref": { + "node_modules/cmdk/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-controllable-state/node_modules/@radix-ui/react-use-callback-ref": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", "integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==", @@ -5784,18 +5992,24 @@ } } }, - "node_modules/cmdk/node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz", - "integrity": "sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==", + "node_modules/cmdk/node_modules/@radix-ui/react-dialog/node_modules/react-remove-scroll": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz", + "integrity": "sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "1.0.1" + "react-remove-scroll-bar": "^2.3.3", + "react-style-singleton": "^2.2.1", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.0", + "use-sidecar": "^1.1.2" + }, + "engines": { + "node": ">=10" }, "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" }, "peerDependenciesMeta": { "@types/react": { @@ -5803,32 +6017,38 @@ } } }, - "node_modules/cmdk/node_modules/@radix-ui/react-use-escape-keydown": { + "node_modules/cmdk/node_modules/@radix-ui/react-primitive": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz", - "integrity": "sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz", + "integrity": "sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "1.0.1" + "@radix-ui/react-slot": "1.0.2" }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/cmdk/node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz", - "integrity": "sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==", + "node_modules/cmdk/node_modules/@radix-ui/react-slot": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", + "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.13.10" + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1" }, "peerDependencies": { "@types/react": "*", @@ -5840,31 +6060,6 @@ } } }, - "node_modules/cmdk/node_modules/react-remove-scroll": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz", - "integrity": "sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==", - "license": "MIT", - "dependencies": { - "react-remove-scroll-bar": "^2.3.3", - "react-style-singleton": "^2.2.1", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.0", - "use-sidecar": "^1.1.2" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -5967,6 +6162,12 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, "node_modules/cspell-config-lib": { "version": "8.8.1", "resolved": "https://registry.npmjs.org/cspell-config-lib/-/cspell-config-lib-8.8.1.tgz", @@ -9131,6 +9332,16 @@ "integrity": "sha512-Yac/bV5sBGkkEXmAX5FWPS9Mmo2rthrOPRQQNfycJPkjUAUclomCPH7QFVCDQ4Mp2k2K1SSM6m0zrxYrOwtFQw==", "license": "MIT" }, + "node_modules/next-themes": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.3.tgz", + "integrity": "sha512-nG84VPkTdUHR2YeD89YchvV4I9RbiMAql3GiLEQlPvq1ioaqPaIReK+yMRdg/zgiXws620qS1rU30TiWmmG9lA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -10048,12 +10259,12 @@ "license": "MIT" }, "node_modules/react-remove-scroll": { - "version": "2.5.7", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.7.tgz", - "integrity": "sha512-FnrTWO4L7/Bhhf3CYBNArEG/yROV0tKmTv7/3h9QCFvH6sndeFf1wPqOcbFVu5VAulS5dV1wGT3GZZ/1GawqiA==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.0.tgz", + "integrity": "sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==", "license": "MIT", "dependencies": { - "react-remove-scroll-bar": "^2.3.4", + "react-remove-scroll-bar": "^2.3.6", "react-style-singleton": "^2.2.1", "tslib": "^2.1.0", "use-callback-ref": "^1.3.0", @@ -10584,6 +10795,16 @@ "url": "https://github.com/sponsors/cyyynthia" } }, + "node_modules/sonner": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.0.tgz", + "integrity": "sha512-W6dH7m5MujEPyug3lpI2l3TC3Pp1+LTgK0Efg+IHDrBbtEjyCmCHHo6yfNBOsf1tFZ6zf+jceWwB38baC8yO9g==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", diff --git a/frontend/package.json b/frontend/package.json index 591ba42..b1449cd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,6 +31,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.0", + "crypto-js": "^4.2.0", "date-fns": "^4.1.0", "fetch-cookie": "^3.0.1", "framer-motion": "^11.3.28", @@ -39,12 +40,14 @@ "lucide-react": "^0.426.0", "next": "^15.0.3", "next-sitemap": "^4.2.3", + "next-themes": "^0.4.3", "node-fetch": "^3.3.2", "oauth-1.0a": "^2.2.6", "react": "^18.3.1", "react-dom": "^18.3.1", "react-icons": "^5.3.0", "sharp": "^0.33.5", + "sonner": "^1.7.0", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", "vaul": "^0.9.2", @@ -54,6 +57,7 @@ "@alergeek-ventures/eslint-config": "^9.0.17", "@next/eslint-plugin-next": "^14.2.7", "@trivago/prettier-plugin-sort-imports": "^4.3.0", + "@types/crypto-js": "^4.2.2", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", diff --git a/frontend/src/actions/logout.ts b/frontend/src/actions/logout.ts index b239c88..84cddc9 100644 --- a/frontend/src/actions/logout.ts +++ b/frontend/src/actions/logout.ts @@ -2,6 +2,8 @@ import { cookies as cookiesPromise } from "next/headers"; +import { env } from "@/env.mjs"; + export const signOutFunction = async () => { const cookies = await cookiesPromise(); cookies.delete({ @@ -12,6 +14,16 @@ export const signOutFunction = async () => { name: "access_token_secret", path: "/", }); + cookies.delete({ + name: "adonis-session", + path: "/", + }); + cookies.delete({ + name: "token", + path: "/", + }); + + await fetch(`${env.NEXT_PUBLIC_API_URL}/user/logout`, { method: "DELETE" }); return true; }; diff --git a/frontend/src/actions/plans.ts b/frontend/src/actions/plans.ts new file mode 100644 index 0000000..8d52e39 --- /dev/null +++ b/frontend/src/actions/plans.ts @@ -0,0 +1,138 @@ +"use server"; + +import { revalidatePath } from "next/cache"; + +import { auth, fetchToAdonis } from "@/lib/auth"; + +interface CreatePlanResponseType { + success: boolean; + message: string; + schedule: { + name: string; + userId: number; + createdAt: string; + updatedAt: string; + id: number; + }; +} + +interface PlanResponseType { + name: string; + userId: number; + id: number; + createdAt: string; + updatedAt: string; + courses: Array<{ + id: string; + name: string; + department: string; + lecturer: string; + type: string; + ects: number; + semester: number; + groups: Array<{ + id: number; + name: string; + day: string; + time: string; + room: string; + }>; + }>; + registrations: Array<{ + id: string; + name: string; + }>; +} + +interface DeletePlanResponseType { + success: boolean; + message: string; +} + +export const createNewPlan = async ({ + name, + courses, + registrations, + groups, +}: { + name: string; + courses: Array<{ id: string }>; + registrations: Array<{ id: string }>; + groups: Array<{ id: number }>; +}) => { + try { + await auth(); + + const data = await fetchToAdonis({ + url: "/user/schedules", + method: "POST", + body: JSON.stringify({ name, courses, registrations, groups }), + }); + if (!data) { + throw new Error("Failed to create new plan"); + } + revalidatePath("/plans"); + return data; + } catch (e) { + throw new Error("Not logged in"); + } +}; + +export const updatePlan = async ({ + id, + name, + courses, + registrations, + groups, +}: { + id: number; + name: string; + courses: Array<{ id: string }>; + registrations: Array<{ id: string }>; + groups: Array<{ id: number }>; +}) => { + await auth(); + + const data = await fetchToAdonis({ + url: `/user/schedules/${id}`, + method: "PATCH", + body: JSON.stringify({ name, courses, registrations, groups }), + }); + if (!data) { + throw new Error("Failed to update plan"); + } + return data; +}; + +export const deletePlan = async ({ id }: { id: number }) => { + try { + await auth(); + const data = await fetchToAdonis({ + url: `/user/schedules/${id}`, + method: "DELETE", + }); + if (!data) { + throw new Error("Failed to delete plan"); + } + revalidatePath("/plans"); + return data; + } catch (e) { + throw new Error("Not logged in"); + } +}; + +export const getPlan = async ({ id }: { id: number }) => { + try { + await auth(); + const data = await fetchToAdonis({ + url: `/user/schedules/${id}`, + method: "GET", + }); + if (!data) { + return false; + } + return data; + } catch (e) { + return null; + } +}; diff --git a/frontend/src/app/api/callback/route.ts b/frontend/src/app/api/callback/route.ts index 76e61c0..c47e3a1 100644 --- a/frontend/src/app/api/callback/route.ts +++ b/frontend/src/app/api/callback/route.ts @@ -1,3 +1,4 @@ +import { revalidatePath } from "next/cache"; import { cookies as cookiesPromise } from "next/headers"; import { redirect } from "next/navigation"; import { type NextRequest } from "next/server"; @@ -82,5 +83,6 @@ export const GET = async (request: NextRequest) => { secure: true, }); + revalidatePath("/plans"); return redirect("/plans"); }; diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index f4ae2ad..c1fbdb3 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -4,6 +4,7 @@ import Script from "next/script"; import type React from "react"; import ClientProviders from "@/components/Providers"; +import { Toaster } from "@/components/ui/sonner"; import { env } from "@/env.mjs"; import { cn } from "@/lib/utils"; import type { UmamiTracker } from "@/types/umami"; @@ -105,6 +106,7 @@ export default function RootLayout({ data-website-id="ab126a0c-c0ab-401b-bf9d-da652aab69ec" data-domains="planer.solvro.pl" /> + diff --git a/frontend/src/app/plans/_components/PlansPage.tsx b/frontend/src/app/plans/_components/PlansPage.tsx new file mode 100644 index 0000000..44688f6 --- /dev/null +++ b/frontend/src/app/plans/_components/PlansPage.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { atom, useAtom } from "jotai"; +import { PlusIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useEffect, useRef } from "react"; +import { toast } from "sonner"; + +import { planFamily } from "@/atoms/planFamily"; +import { plansIds } from "@/atoms/plansIds"; +import { PlanItem } from "@/components/PlanItem"; + +import type { PlanResponseDataType } from "../page"; + +const plansAtom = atom( + (get) => get(plansIds).map((id) => get(planFamily(id))), + (get, set, values: Array<{ id: string }>) => { + set(plansIds, values); + }, +); +export function PlansPage({ + plans: onlinePlans, +}: { + plans: PlanResponseDataType[]; +}) { + const [plans, setPlans] = useAtom(plansAtom); + const router = useRouter(); + const firstTime = useRef(true); + + const addNewPlan = () => { + const uuid = crypto.randomUUID(); + const newPlan = { + id: uuid, + }; + + void window.umami?.track("Create plan", { + numberOfPlans: plans.length, + }); + + router.push(`/plans/edit/${newPlan.id}`); + setPlans([...plans, newPlan]); + }; + + const plansExistingLocallyAndDeletedOnline = plans.filter( + (plan) => + plan.onlineId !== null && + !onlinePlans.some((p) => p.id.toString() === plan.onlineId), + ); + + const handleDeleteDeletedPlans = () => { + firstTime.current = false; + setPlans( + plans.filter( + (plan) => + !plansExistingLocallyAndDeletedOnline.some((p) => p.id === plan.id), + ), + ); + toast.success("Usunięto plany, które usunąłeś na innym urządzeniu.", { + duration: 5000, + }); + }; + + useEffect(() => { + if (firstTime.current && plansExistingLocallyAndDeletedOnline.length > 0) { + handleDeleteDeletedPlans(); + } + }, [plansExistingLocallyAndDeletedOnline]); + + return ( +
+
+ + {plans.map((plan) => ( + + ))} + {onlinePlans.map((plan) => { + if (plans.some((p) => p.onlineId === plan.id.toString())) { + return null; + } + return ( + c.groups).length} + registrationCount={plan.registrations.length} + updatedAt={new Date(plan.updatedAt)} + /> + ); + })} +
+
+ ); +} diff --git a/frontend/src/app/plans/create/[id]/_components/CreateNewPlanPage.tsx b/frontend/src/app/plans/edit/[id]/_components/CreateNewPlanPage.tsx similarity index 59% rename from frontend/src/app/plans/create/[id]/_components/CreateNewPlanPage.tsx rename to frontend/src/app/plans/edit/[id]/_components/CreateNewPlanPage.tsx index 982d85b..b4f733f 100644 --- a/frontend/src/app/plans/create/[id]/_components/CreateNewPlanPage.tsx +++ b/frontend/src/app/plans/edit/[id]/_components/CreateNewPlanPage.tsx @@ -1,10 +1,14 @@ "use client"; import { useMutation, useQuery } from "@tanstack/react-query"; +import { isEqual } from "date-fns"; import Link from "next/link"; -import { useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; import { MdArrowBack } from "react-icons/md"; +import { toast } from "sonner"; +import { createNewPlan, getPlan, updatePlan } from "@/actions/plans"; import type { ExtendedCourse, ExtendedGroup } from "@/atoms/planFamily"; import { ClassSchedule } from "@/components/ClassSchedule"; import { GroupsAccordionItem } from "@/components/GroupsAccordion"; @@ -27,6 +31,9 @@ import { registrationReplacer } from "@/lib/utils"; import type { LessonType } from "@/services/usos/types"; import { Day } from "@/services/usos/types"; +import { SyncErrorAlert } from "./SyncErrorAlert"; +import { SyncedButton } from "./SyncedButton"; + type CourseType = Array<{ id: string; name: string; @@ -57,10 +64,94 @@ export function CreateNewPlanPage({ planId: string; faculties: Array<{ name: string; value: string }>; }) { + const [syncing, setSyncing] = useState(false); + const [bouncingAlert, setBouncingAlert] = useState(false); + const firstTime = useRef(true); + const router = useRouter(); + const plan = usePlan({ planId, }); + const bounceAlert = () => { + setBouncingAlert(true); + setTimeout(() => { + setBouncingAlert(false); + }, 1000); + }; + + const handleCreateOnlinePlan = async () => { + firstTime.current = false; + const courses = plan.courses + .filter((c) => c.isChecked) + .map((c) => ({ id: c.id })); + const registrations = plan.registrations.map((r) => ({ id: r.id })); + const groups = plan.allGroups + .filter((g) => g.isChecked) + .map((g) => ({ id: g.groupOnlineId })); + try { + const res = await createNewPlan({ + name: plan.name, + courses, + registrations, + groups, + }); + plan.setPlan((prev) => ({ + ...prev, + synced: true, + updatedAt: new Date(res.schedule.updatedAt), + onlineId: res.schedule.id.toString(), + })); + toast.success("Utworzono plan"); + return true; + } catch (error) { + return toast.error("Nie udało się utworzyć planu w wersji online", { + description: "Zaloguj się i spróbuj ponownie", + duration: 5000, + }); + } + }; + + const handleSyncPlan = async () => { + setSyncing(true); + try { + const res = await updatePlan({ + id: Number(plan.onlineId), + name: plan.name, + courses: plan.courses + .filter((c) => c.isChecked) + .map((c) => ({ id: c.id })), + registrations: plan.registrations.map((r) => ({ id: r.id })), + groups: plan.allGroups + .filter((g) => g.isChecked) + .map((g) => ({ id: g.groupOnlineId })), + }); + if (!res.success) { + return toast.error("Nie udało się zaktualizować planu"); + } + await refetchOnlinePlan(); + toast.success("Zaktualizowano plan"); + plan.setPlan((prev) => ({ + ...prev, + synced: true, + updatedAt: res.schedule.updatedAt + ? new Date(res.schedule.updatedAt) + : new Date(), + })); + return true; + } catch (error) { + return toast.error("Nie udało się zaktualizować planu"); + } finally { + setSyncing(false); + } + }; + + useEffect(() => { + if (plan.onlineId === null && firstTime.current) { + void handleCreateOnlinePlan(); + } + }, [plan]); + const inputRef = useRef(null); const [faculty, setFaculty] = useState(null); @@ -69,7 +160,7 @@ export function CreateNewPlanPage({ queryKey: ["registrations", faculty], queryFn: async () => { const response = await fetch( - `https://planer.solvro.pl/api/v1/departments/${faculty}/registrations`, + `${process.env.NEXT_PUBLIC_API_URL}/departments/${faculty}/registrations`, ); if (!response.ok) { @@ -80,11 +171,33 @@ export function CreateNewPlanPage({ }, }); + const { + data: onlinePlan, + refetch: refetchOnlinePlan, + isLoading, + } = useQuery({ + enabled: plan.onlineId !== null, + queryKey: ["onlinePlan", plan.onlineId], + queryFn: async () => { + const res = await getPlan({ id: Number(plan.onlineId) }); + if ( + res === false || + (res as unknown as { status: number }).status === 404 + ) { + plan.remove(); + toast.error("Nie udało się pobrać planu"); + router.push("/plans"); + return null; + } + return res; + }, + }); + const coursesFn = useMutation({ mutationKey: ["courses"], mutationFn: async (registrationId: string) => { const response = await fetch( - `https://planer.solvro.pl/api/v1/departments/${faculty}/registrations/${encodeURIComponent(registrationId)}/courses`, + `${process.env.NEXT_PUBLIC_API_URL}/departments/${faculty}/registrations/${encodeURIComponent(registrationId)}/courses`, ); if (!response.ok) { @@ -95,12 +208,123 @@ export function CreateNewPlanPage({ }, }); + const handleUpdateLocalPlan = async () => { + firstTime.current = false; + + if (!onlinePlan) { + return false; + } + + let updatedRegistrations: typeof plan.registrations = []; + let updatedCourses: typeof plan.courses = []; + + for (const registration of onlinePlan.registrations) { + try { + const courses = await coursesFn.mutateAsync(registration.id); + const extendedCourses: ExtendedCourse[] = courses + .map((c) => { + const groups: ExtendedGroup[] = c.groups.map((g) => ({ + groupId: g.group + c.id + g.type, + groupNumber: g.group.toString(), + groupOnlineId: g.id, + courseId: c.id, + courseName: c.name, + isChecked: + onlinePlan.courses + .find((oc) => oc.id === c.id) + ?.groups.some((og) => og.id === g.id) ?? false, + courseType: g.type, + day: g.day, + lecturer: g.lecturer, + registrationId: c.registrationId, + week: g.week.replace("-", "") as "" | "TN" | "TP", + endTime: g.endTime.split(":").slice(0, 2).join(":"), + startTime: g.startTime.split(":").slice(0, 2).join(":"), + })); + return { + id: c.id, + name: c.name, + isChecked: onlinePlan.courses.some((oc) => oc.id === c.id), + registrationId: c.registrationId, + type: c.groups.at(0)?.type ?? ("" as LessonType), + groups, + }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); + + // List update logic: + // Add unique registrations to the updatedRegistrations array + // r - current registration + // i - current index + // a - array of registrations + updatedRegistrations = [...updatedRegistrations, registration].filter( + (r, i, a) => a.findIndex((t) => t.id === r.id) === i, + ); + + // Add unique courses to the updatedCourses array + // c - current course + // i - current index + // a - array of courses + updatedCourses = [...updatedCourses, ...extendedCourses].filter( + (c, i, a) => a.findIndex((t) => t.id === c.id) === i, + ); + } catch { + toast.error("Nie udało się pobrać kursów"); + } + } + + plan.setPlan({ + ...plan, + registrations: updatedRegistrations, + courses: updatedCourses, + synced: true, + toCreate: false, + updatedAt: new Date(onlinePlan.updatedAt), + }); + + return true; + }; + + useEffect(() => { + if ( + onlinePlan && + plan.onlineId !== null && + plan.toCreate && + firstTime.current + ) { + void handleUpdateLocalPlan(); + } + }, [plan, onlinePlan]); + + const downloadChanges = () => { + void handleUpdateLocalPlan(); + }; + const uploadChanges = () => { + void handleSyncPlan(); + }; + + if (isLoading) { + return ( +
+ loading... +
+ ); + } + return (
+ +
-
-
+
+
+
@@ -207,6 +442,7 @@ export function CreateNewPlanPage({ ({ groupId: g.group + c.id + g.type, groupNumber: g.group.toString(), + groupOnlineId: g.id, courseId: c.id, courseName: c.name, isChecked: false, diff --git a/frontend/src/app/plans/edit/[id]/_components/SyncErrorAlert.tsx b/frontend/src/app/plans/edit/[id]/_components/SyncErrorAlert.tsx new file mode 100644 index 0000000..1cc757b --- /dev/null +++ b/frontend/src/app/plans/edit/[id]/_components/SyncErrorAlert.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { formatDistance, isAfter, isEqual } from "date-fns"; +import { pl } from "date-fns/locale"; +import { DownloadCloudIcon, Loader2Icon, UploadCloudIcon } from "lucide-react"; +import React, { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +interface PlanResponseType { + name: string; + userId: number; + id: number; + createdAt: string; + updatedAt: string; + courses: Array<{ + id: string; + name: string; + department: string; + lecturer: string; + type: string; + ects: number; + semester: number; + groups: Array<{ + id: number; + name: string; + day: string; + time: string; + room: string; + }>; + }>; + registrations: Array<{ + id: string; + name: string; + }>; +} + +export const SyncErrorAlert = ({ + onlinePlan, + planDate, + bounce = false, + downloadChanges, + sendChanges, +}: { + onlinePlan: PlanResponseType | null | undefined; + planDate: Date; + bounce?: boolean; + downloadChanges: () => void; + sendChanges: () => void; +}) => { + const [loadingSending, setLoadingSending] = useState(false); + const [loadingDownloading, setLoadingDownloading] = useState(false); + if (!onlinePlan) { + return null; + } + if (isEqual(planDate, new Date(onlinePlan.updatedAt))) { + return null; + } + + const timePassed = formatDistance(planDate, new Date(onlinePlan.updatedAt), { + addSuffix: false, + locale: pl, + }); + + const clearLoading = () => { + setTimeout(() => { + setLoadingDownloading(false); + setLoadingSending(false); + }, 5000); + }; + + return ( +
+
+
+

+ Wystąpił konflikt w chmurze! +

+

+ Posiadasz{" "} + + {isAfter(planDate, onlinePlan.updatedAt) + ? "najnowszą" + : "starszą"} + {" "} + wersję o {timePassed} w + porównaniu do wersji zapisanej w chmurze. +

+
+
+
+ + +
+
+ ); +}; diff --git a/frontend/src/app/plans/edit/[id]/_components/SyncedButton.tsx b/frontend/src/app/plans/edit/[id]/_components/SyncedButton.tsx new file mode 100644 index 0000000..7d81613 --- /dev/null +++ b/frontend/src/app/plans/edit/[id]/_components/SyncedButton.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { + AlertTriangleIcon, + CloudIcon, + GitPullRequestClosed, + RefreshCwIcon, + RefreshCwOffIcon, +} from "lucide-react"; +import React from "react"; + +import { Button } from "@/components/ui/button"; + +export const SyncedButton = ({ + synced, + onlineId, + syncing, + onClick, + bounceAlert, + equalsDates, +}: { + synced: boolean; + onlineId: string | null; + syncing: boolean; + onClick: () => Promise; + bounceAlert: () => void; + equalsDates: boolean; +}) => { + return ( + + ); +}; diff --git a/frontend/src/app/plans/create/[id]/page.tsx b/frontend/src/app/plans/edit/[id]/page.tsx similarity index 96% rename from frontend/src/app/plans/create/[id]/page.tsx rename to frontend/src/app/plans/edit/[id]/page.tsx index 408313f..234bbda 100644 --- a/frontend/src/app/plans/create/[id]/page.tsx +++ b/frontend/src/app/plans/edit/[id]/page.tsx @@ -19,7 +19,7 @@ export default async function CreateNewPlan({ params }: PageProps) { } const facultiesRes = (await fetch( - "https://planer.solvro.pl/api/v1/departments", + `${process.env.NEXT_PUBLIC_API_URL}/departments`, ).then((r) => r.json())) as Array<{ id: string; name: string }> | null; if (!facultiesRes) { return notFound(); diff --git a/frontend/src/app/plans/page.tsx b/frontend/src/app/plans/page.tsx index 19bfd71..4f6de13 100644 --- a/frontend/src/app/plans/page.tsx +++ b/frontend/src/app/plans/page.tsx @@ -1,49 +1,32 @@ -"use client"; +import React from "react"; -import { atom, useAtom } from "jotai"; -import { PlusIcon } from "lucide-react"; -import { useRouter } from "next/navigation"; +import type { ExtendedCourse } from "@/atoms/planFamily"; +import { fetchToAdonis } from "@/lib/auth"; +import type { Registration } from "@/lib/types"; -import { planFamily } from "@/atoms/planFamily"; -import { plansIds } from "@/atoms/plansIds"; -import { PlanItem } from "@/components/PlanItem"; +import { PlansPage } from "./_components/PlansPage"; -const plansAtom = atom( - (get) => get(plansIds).map((id) => get(planFamily(id))), - (get, set, values: Array<{ id: string }>) => { - set(plansIds, values); - }, -); -export default function Plans() { - const [plans, setPlans] = useAtom(plansAtom); - const router = useRouter(); - const addNewPlan = () => { - const uuid = crypto.randomUUID(); - const newPlan = { - id: uuid, - }; +export interface PlanResponseDataType { + id: number; + userId: number; + name: string; + createdAt: string; + updatedAt: string; + courses: ExtendedCourse[]; + registrations: Registration[]; +} + +export interface ErrorResponse { + error: string; +} - void window.umami?.track("Create plan", { - numberOfPlans: plans.length, - }); +type PlanResponse = ErrorResponse | PlanResponseDataType[]; - router.push(`/plans/create/${newPlan.id}`); - setPlans([...plans, newPlan]); - }; +export default async function Plans() { + const data = await fetchToAdonis({ + url: "/user/schedules", + method: "GET", + }); - return ( -
-
- - {plans.map((plan) => ( - - ))} -
-
- ); + return ; } diff --git a/frontend/src/app/plans/preview/[id]/_components/SharePlanPage.tsx b/frontend/src/app/plans/preview/[id]/_components/SharePlanPage.tsx index 2c5bb6c..0768d73 100644 --- a/frontend/src/app/plans/preview/[id]/_components/SharePlanPage.tsx +++ b/frontend/src/app/plans/preview/[id]/_components/SharePlanPage.tsx @@ -37,7 +37,7 @@ export function SharePlanPage({ planId }: { planId: string }) { }); setTimeout(() => { - router.push(`/plans/create/${newPlan.id}`); + router.push(`/plans/edit/${newPlan.id}`); }, 200); }; return ( diff --git a/frontend/src/atoms/planFamily.ts b/frontend/src/atoms/planFamily.ts index bc38b9b..bc0fb4c 100644 --- a/frontend/src/atoms/planFamily.ts +++ b/frontend/src/atoms/planFamily.ts @@ -21,6 +21,10 @@ export const planFamily = atomFamily( courses: [] as ExtendedCourse[], registrations: [] as Registration[], createdAt: new Date(), + updatedAt: new Date(), + onlineId: null as string | null, + toCreate: true as boolean, + synced: false, }, undefined, { getOnInit: true }, diff --git a/frontend/src/components/PlanDisplayLink.tsx b/frontend/src/components/PlanDisplayLink.tsx index bf4af23..308c26e 100644 --- a/frontend/src/components/PlanDisplayLink.tsx +++ b/frontend/src/components/PlanDisplayLink.tsx @@ -1,3 +1,4 @@ +import { FolderSearch } from "lucide-react"; import Link from "next/link"; import { cn } from "@/lib/utils"; @@ -8,9 +9,9 @@ export function PlanDisplayLink({ id }: { id: string }) { return ( - Podgląd + ); } diff --git a/frontend/src/components/PlanItem.tsx b/frontend/src/components/PlanItem.tsx index e6748c2..1a4bd98 100644 --- a/frontend/src/components/PlanItem.tsx +++ b/frontend/src/components/PlanItem.tsx @@ -5,13 +5,16 @@ import { useAtom } from "jotai"; import { CopyIcon, EllipsisVerticalIcon, + Loader2Icon, Pencil, TrashIcon, } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import React from "react"; +import { toast } from "sonner"; +import { deletePlan } from "@/actions/plans"; import { plansIds } from "@/atoms/plansIds"; import { Dialog, @@ -41,21 +44,42 @@ import { CardHeader, CardTitle, } from "./ui/card"; +import { StatusIcon } from "./ui/status-icon"; -export const PlanItem = ({ id, name }: { id: string; name: string }) => { +export const PlanItem = ({ + id, + name, + synced, + onlineId, + onlineOnly = false, + groupCount = 0, + registrationCount = 0, + updatedAt = new Date(), +}: { + id: string; + name: string; + synced: boolean; + onlineId: string | null; + onlineOnly?: boolean; + groupCount?: number; + registrationCount?: number; + updatedAt?: Date; +}) => { const uuid = React.useMemo(() => crypto.randomUUID(), []); + const uuidToCopy = React.useMemo(() => crypto.randomUUID(), []); const [plans, setPlans] = useAtom(plansIds); - const plan = usePlan({ planId: id }); + const plan = usePlan({ planId: onlineOnly ? uuid : id }); const planToCopy = usePlan({ planId: uuid }); const router = useRouter(); const [dialogOpened, setDialogOpened] = React.useState(false); const [dropdownOpened, setDropdownOpened] = React.useState(false); + const [loading, setLoading] = React.useState(false); const copyPlan = () => { setDropdownOpened(false); const newPlan = { - id: uuid, + id: uuidToCopy, }; void window.umami?.track("Create plan", { @@ -69,39 +93,71 @@ export const PlanItem = ({ id, name }: { id: string; name: string }) => { }); setTimeout(() => { - router.push(`/plans/create/${newPlan.id}`); + router.push(`/plans/edit/${newPlan.id}`); + }, 200); + }; + + const createFromOnlinePlan = () => { + const newPlan = { + id: uuid, + }; + + setPlans([...plans, newPlan]); + plan.setPlan({ + ...plan, + id: uuid, + onlineId, + name, + }); + + setTimeout(() => { + router.push(`/plans/edit/${newPlan.id}`); }, 200); }; - const deletePlan = () => { + const handleDeletePlan = async () => { + setLoading(true); plan.remove(); - setPlans(plans.filter((p) => p.id !== id)); + if (!onlineOnly) { + setPlans(plans.filter((p) => p.id !== id)); + } + if (onlineId !== null) { + await deletePlan({ id: Number(onlineId) }); + } + toast.success("Plan został usunięty."); }; - const groupCount = plan.courses + const groupCountLocal = plan.courses .flatMap((c) => c.groups) .filter((group) => group.isChecked).length; return ( - - - {name} + + + + {name} + {format( - (plan.createdAt as Date | undefined) ?? new Date(), + onlineOnly ? updatedAt : plan.createdAt, "dd.MM.yyyy - HH:mm", )}

- {plan.registrations.length}{" "} - {pluralize(plan.registrations.length, "kurs", "kursy", "kursów")} + {registrationCount || plan.registrations.length}{" "} + {pluralize( + registrationCount || plan.registrations.length, + "kurs", + "kursy", + "kursów", + )}

- {groupCount}{" "} + {groupCount || groupCountLocal}{" "} {pluralize( - groupCount, + groupCount || groupCountLocal, "wybrana grupa", "wybrane grupy", "wybranych grup", @@ -133,14 +189,23 @@ export const PlanItem = ({ id, name }: { id: string; name: string }) => { - + + ) : ( + + )} + +

{ Anuluj diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index f7245df..94983a2 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -28,6 +28,7 @@ const buttonVariants = cva( lg: "h-11 rounded-md px-8", icon: "h-10 w-10", iconSm: "h-9 w-9", + xs: "h-7 px-3 py-1.5", }, }, defaultVariants: { diff --git a/frontend/src/components/ui/signout-button.tsx b/frontend/src/components/ui/signout-button.tsx index 41eb400..15d7247 100644 --- a/frontend/src/components/ui/signout-button.tsx +++ b/frontend/src/components/ui/signout-button.tsx @@ -16,6 +16,7 @@ export function SignOutButton({ if (asChild) { const signOut = async () => { await signOutFunction(); + localStorage.clear(); // refresh window.location.reload(); }; @@ -35,6 +36,7 @@ export function SignOutButton({
{ void signOutFunction(); + localStorage.clear(); }} > diff --git a/frontend/src/components/ui/sonner.tsx b/frontend/src/components/ui/sonner.tsx new file mode 100644 index 0000000..11e3e6b --- /dev/null +++ b/frontend/src/components/ui/sonner.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { useTheme } from "next-themes"; +import { Toaster as Sonner } from "sonner"; + +type ToasterProps = React.ComponentProps; + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme(); + + return ( + + ); +}; + +export { Toaster }; diff --git a/frontend/src/components/ui/status-icon.tsx b/frontend/src/components/ui/status-icon.tsx new file mode 100644 index 0000000..e3274d2 --- /dev/null +++ b/frontend/src/components/ui/status-icon.tsx @@ -0,0 +1,22 @@ +import { AlertTriangleIcon, CloudIcon, RefreshCwOffIcon } from "lucide-react"; +import React from "react"; + +export const StatusIcon = ({ + synced, + onlineId, +}: { + synced: boolean; + onlineId: string | null; +}) => { + return ( +
+ {synced ? ( + + ) : !(onlineId ?? "") ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/frontend/src/env.mjs b/frontend/src/env.mjs index a174e9b..4f34a27 100644 --- a/frontend/src/env.mjs +++ b/frontend/src/env.mjs @@ -7,9 +7,14 @@ export const env = createEnv({ USOS_CONSUMER_SECRET: z.string().min(1), USOS_BASE_URL: z.string().startsWith("usos").default("usos.pwr.edu.pl"), SITE_URL: z.string().url().default("http://localhost:3000"), + NEXT_PUBLIC_API_URL: z.string().url().default("http://localhost:3000/api"), + }, + client: { + NEXT_PUBLIC_API_URL: z.string().url(), }, runtimeEnv: { SITE_URL: process.env.SITE_URL, + NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, USOS_BASE_URL: process.env.USOS_BASE_URL, USOS_CONSUMER_KEY: process.env.USOS_CONSUMER_KEY, USOS_CONSUMER_SECRET: process.env.USOS_CONSUMER_SECRET, diff --git a/frontend/src/lib/auth/index.ts b/frontend/src/lib/auth/index.ts index 24edddb..a02346a 100644 --- a/frontend/src/lib/auth/index.ts +++ b/frontend/src/lib/auth/index.ts @@ -1,13 +1,19 @@ -import crypto from "crypto"; +import CryptoJS, { HmacSHA1 } from "crypto-js"; +import { cookies as cookiesPromise } from "next/headers"; import OAuth from "oauth-1.0a"; import { env } from "@/env.mjs"; +function createHmacSha1Base64(base_string: string, key: string) { + const hmac: CryptoJS.lib.WordArray = HmacSHA1(base_string, key); + return CryptoJS.enc.Base64.stringify(hmac); +} + export const oauth = new OAuth({ consumer: { key: env.USOS_CONSUMER_KEY, secret: env.USOS_CONSUMER_SECRET }, signature_method: "HMAC-SHA1", hash_function(base_string, key) { - return crypto.createHmac("sha1", key).update(base_string).digest("base64"); + return createHmacSha1Base64(base_string, key); }, }); @@ -86,3 +92,109 @@ export async function getRequestToken() { secret: params.get("oauth_token_secret"), }; } + +export const auth = async (tokens?: { + token: string | undefined; + secret: string | undefined; +}) => { + const cookies = await cookiesPromise(); + const accessToken = tokens?.token ?? cookies.get("access_token")?.value; + const accessSecret = + tokens?.secret ?? cookies.get("access_token_secret")?.value; + + if (accessToken === "" || accessSecret === "") { + if (tokens) { + return null; + } + throw new Error("No access token or access secret"); + } + + try { + const response = await fetch(`${env.NEXT_PUBLIC_API_URL}/user/login`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ accessToken, accessSecret }), + credentials: "include", + }); + const data = (await response.json()) as + | { + firstName: string; + lastName: string; + studentNumber: number; + usosId: string; + } + | { error: string }; + if ("error" in data) { + cookies.delete({ + name: "access_token", + path: "/", + }); + cookies.delete({ + name: "access_token_secret", + path: "/", + }); + if (tokens) { + return null; + } + throw new Error(data.error); + } + const setCookieHeaders = response.headers.getSetCookie(); + setCookieHeaders.forEach((cookie) => { + const preparedCookie = cookie.replace(";", ""); + const [name, value] = preparedCookie.split("="); + cookies.set({ + name, + value, + path: "/", + maxAge: 60 * 60 * 24 * 7, + httpOnly: true, + secure: true, + }); + }); + return data; + } catch (error) { + if (tokens) { + return null; + } + throw new Error("Failed to authenticate"); + } +}; + +export const fetchToAdonis = async ({ + url, + method, + body, +}: { + url: string; + method: RequestInit["method"]; + body?: string | null; +}): Promise => { + try { + const cookies = await cookiesPromise(); + const adonisSession = cookies.get("adonis-session")?.value; + const token = cookies.get("token")?.value; + const fetchOptions: RequestInit = { + method, + headers: { + "Content-Type": "application/json", + Cookie: `adonis-session=${adonisSession}; token=${token}`, + }, + credentials: "include", + }; + + if (method !== "GET" && method !== "HEAD" && body !== undefined) { + fetchOptions.body = body; + } + + const response = await fetch( + `${env.NEXT_PUBLIC_API_URL}${url}`, + fetchOptions, + ); + const data = (await response.json()) as T; + return data; + } catch (error) { + return null; + } +}; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 61ffadd..b9093bd 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -5,6 +5,7 @@ export interface ClassBlockProps { endTime: string; groupId: string; groupNumber: string; + groupOnlineId: number; courseId: string; courseName: string; lecturer: string; diff --git a/frontend/src/lib/usePlan.ts b/frontend/src/lib/usePlan.ts index 6ad0329..3e6d9de 100644 --- a/frontend/src/lib/usePlan.ts +++ b/frontend/src/lib/usePlan.ts @@ -26,6 +26,7 @@ export const usePlan = ({ planId }: { planId: string }) => { : group, ), })), + synced: false, }); }, checkAllCourses: (registrationId: string, isChecked?: boolean) => { @@ -36,18 +37,27 @@ export const usePlan = ({ planId }: { planId: string }) => { ? { ...course, isChecked: isChecked ?? !course.isChecked } : course, ), + synced: false, }); }, addRegistration: ( registration: Registration, courses: ExtendedCourse[], + firstTime = false, + updatedAt?: Date, ) => { setPlan({ ...plan, registrations: [...plan.registrations, registration].filter( (r, i, a) => a.findIndex((t) => t.id === r.id) === i, ), - courses: [...plan.courses, ...courses], + courses: [...plan.courses, ...courses].filter( + (c, i, a) => a.findIndex((t) => t.id === c.id) === i, + ), + + synced: firstTime, + toCreate: false, + updatedAt: updatedAt ?? plan.updatedAt, }); }, removeRegistration: (registrationId: string) => { @@ -59,6 +69,7 @@ export const usePlan = ({ planId }: { planId: string }) => { courses: plan.courses.filter( (c) => c.registrationId !== registrationId, ), + synced: false, }); }, changeName: (newName: string) => { @@ -66,6 +77,20 @@ export const usePlan = ({ planId }: { planId: string }) => { setPlan({ ...plan, name: newName, + synced: false, + }); + }, + setOnlineId: (onlineId: string) => { + setPlan({ + ...plan, + onlineId, + synced: true, + }); + }, + setSynced: (synced: boolean) => { + setPlan({ + ...plan, + synced, }); }, selectCourse: (courseId: string, isChecked?: boolean) => { @@ -77,6 +102,7 @@ export const usePlan = ({ planId }: { planId: string }) => { ? { ...course, isChecked: isChecked ?? !course.isChecked } : course, ), + synced: false, }); }, }; diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index 9da6034..83d2aa7 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -1,22 +1,22 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; -export function middleware(request: NextRequest) { +import { auth } from "./lib/auth"; + +export async function middleware(request: NextRequest) { const tokens = { token: request.cookies.get("access_token")?.value, secret: request.cookies.get("access_token_secret")?.value, - fetch, }; const isProtectedRoute = request.nextUrl.pathname.startsWith("/account"); + const user = await auth(tokens); if (!isProtectedRoute) { return NextResponse.next(); } - const isFailed = tokens.token === undefined || tokens.secret === undefined; - - if (isFailed) { + if (user === null) { return NextResponse.redirect(new URL("/", request.url)); } diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index 290a6e8..9c67d43 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -145,6 +145,16 @@ const config = { from: { height: "var(--radix-accordion-content-height)" }, to: { height: "0" }, }, + "fast-bounce": { + "0%, 100%": { + transform: "translateY(0%)", + "animation-timing-function": "cubic-bezier(0.8, 0, 1, 1)", + }, + "50%": { + transform: "translateY(-10%)", + "animation-timing-function": "cubic-bezier(0, 0, 0.2, 1)", + }, + }, }, animation: { "waving-hand": "flip 1s infinite", @@ -157,6 +167,7 @@ const config = { gradient: "gradient 3s linear infinite", "accordion-down": "accordion-down 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out", + "fast-bounce": "fast-bounce 1s", }, gridColumnStart: { "13": "13",