From 2f7a2924157d6a0bf467591e7458f52b33962d2a Mon Sep 17 00:00:00 2001 From: Ezra Khairan Permana Date: Thu, 9 May 2024 02:53:54 +0700 Subject: [PATCH 01/44] feat: from scratch --- .circleci/config.yml | 165 - .env.example | 26 - .eslintrc.js | 22 - .github/actions/yarn-nm-install/action.yml | 59 - .github/workflows/ci.yml | 31 - .github/workflows/codeql.yml | 75 - .github/workflows/e2e.yml | 62 - .gitignore | 37 +- .npmrc | 13 - .nvmrc | 2 +- .vscode/extensions.json | 7 +- .vscode/launch.json | 14 +- .vscode/settings.json | 20 +- .yarnrc.yml | 2 +- apps/auth-proxy/.env.example | 7 + apps/auth-proxy/README.md | 22 + apps/auth-proxy/eslint.config.js | 9 + apps/auth-proxy/package.json | 29 + apps/auth-proxy/routes/[...auth].ts | 17 + apps/auth-proxy/tsconfig.json | 4 + apps/desktop/attendance/.eslintignore | 4 - apps/desktop/attendance/.eslintrc.json | 49 - apps/desktop/attendance/.gitignore | 4 - apps/desktop/attendance/.prettierignore | 6 - .../attendance/build/entitlements.mac.plist | 12 - apps/desktop/attendance/build/icon.icns | Bin 85649 -> 0 bytes apps/desktop/attendance/build/icon.ico | Bin 7044 -> 0 bytes apps/desktop/attendance/build/icon.png | Bin 13735 -> 0 bytes apps/desktop/attendance/build/notarize.js | 38 - apps/desktop/attendance/electron-builder.yml | 42 - .../attendance/electron.vite.config.ts | 20 - apps/desktop/attendance/package.json | 67 - apps/desktop/attendance/resources/icon.png | Bin 13735 -> 0 bytes apps/desktop/attendance/src/main/index.ts | 124 - .../desktop/attendance/src/preload/index.d.ts | 8 - apps/desktop/attendance/src/preload/index.ts | 22 - .../attendance/src/renderer/index.html | 17 - .../attendance/src/renderer/src/App.tsx | 66 - .../src/renderer/src/UpperProvider.tsx | 21 - .../src/components/PreScan/CantAttend.tsx | 25 - .../src/components/PreScan/ErrorOccured.tsx | 34 - .../src/components/Scanner/NormalScanner.tsx | 92 - .../src/components/Scanner/ScanningError.tsx | 45 - .../src/components/Scanner/SuccessScan.tsx | 52 - .../renderer/src/components/Scanner/index.tsx | 45 - .../src/renderer/src/context/AppSetting.tsx | 90 - .../renderer/src/context/SettingContext.tsx | 72 - .../attendance/src/renderer/src/env.d.ts | 1 - .../attendance/src/renderer/src/main.tsx | 13 - .../src/renderer/src/routes/Main.tsx | 21 - .../src/styles/components/Scanner.module.css | 4 - .../attendance/src/renderer/src/utils/trpc.ts | 8 - apps/desktop/attendance/tsconfig.json | 7 - apps/desktop/attendance/tsconfig.node.json | 8 - apps/desktop/attendance/tsconfig.web.json | 21 - apps/desktop/chooser/.editorconfig | 9 - apps/desktop/chooser/.eslintignore | 4 - apps/desktop/chooser/.eslintrc.json | 49 - apps/desktop/chooser/.gitignore | 4 - apps/desktop/chooser/.prettierignore | 6 - .../chooser/build/entitlements.mac.plist | 12 - apps/desktop/chooser/build/icon.icns | Bin 85649 -> 0 bytes apps/desktop/chooser/build/icon.ico | Bin 7044 -> 0 bytes apps/desktop/chooser/build/icon.png | Bin 13735 -> 0 bytes apps/desktop/chooser/build/notarize.js | 38 - apps/desktop/chooser/electron-builder.yml | 42 - apps/desktop/chooser/electron.vite.config.ts | 20 - apps/desktop/chooser/package.json | 69 - apps/desktop/chooser/resources/icon.png | Bin 13735 -> 0 bytes apps/desktop/chooser/src/main/index.ts | 157 - apps/desktop/chooser/src/main/keyboard.ts | 73 - apps/desktop/chooser/src/preload/index.d.ts | 8 - apps/desktop/chooser/src/preload/index.ts | 22 - apps/desktop/chooser/src/renderer/index.html | 17 - apps/desktop/chooser/src/renderer/src/App.tsx | 74 - .../src/renderer/src/UpperProvider.tsx | 21 - .../AfterVote/BerhasilMemilihDanCapJari.tsx | 28 - .../src/components/PreScan/CantVote.tsx | 25 - .../src/components/PreScan/ErrorOccured.tsx | 34 - .../components/PreScan/InvalidCandidate.tsx | 32 - .../src/components/Scanner/NormalScanner.tsx | 81 - .../renderer/src/components/Scanner/index.tsx | 53 - .../src/components/UniversalErrorHandler.tsx | 51 - .../src/renderer/src/context/AppSetting.tsx | 48 - .../src/context/ParticipantContext.tsx | 53 - .../renderer/src/context/SettingContext.tsx | 110 - .../desktop/chooser/src/renderer/src/env.d.ts | 1 - .../desktop/chooser/src/renderer/src/main.tsx | 13 - .../chooser/src/renderer/src/routes/Main.tsx | 25 - .../chooser/src/renderer/src/routes/Vote.tsx | 306 - .../src/styles/components/Scanner.module.css | 4 - .../chooser/src/renderer/src/utils/trpc.ts | 8 - apps/desktop/chooser/tsconfig.json | 7 - apps/desktop/chooser/tsconfig.node.json | 8 - apps/desktop/chooser/tsconfig.web.json | 18 - apps/{web => nextjs}/README.md | 2 +- apps/nextjs/eslint.config.js | 14 + .../next.config.mjs => nextjs/next.config.js} | 22 +- apps/nextjs/package.json | 50 + apps/nextjs/postcss.config.cjs | 5 + apps/nextjs/public/favicon.ico | Bin 0 -> 103027 bytes apps/nextjs/public/t3-icon.svg | 13 + .../src/app/_components/auth-showcase.tsx | 42 + apps/nextjs/src/app/_components/posts.tsx | 176 + .../src/app/api/auth/[...nextauth]/route.ts | 3 + apps/nextjs/src/app/api/trpc/[trpc]/route.ts | 45 + apps/nextjs/src/app/globals.css | 50 + apps/nextjs/src/app/layout.tsx | 63 + apps/nextjs/src/app/page.tsx | 42 + apps/nextjs/src/env.ts | 44 + apps/nextjs/src/middleware.ts | 11 + apps/nextjs/src/trpc/react.tsx | 75 + apps/nextjs/src/trpc/server.ts | 20 + apps/nextjs/tailwind.config.ts | 18 + apps/nextjs/tsconfig.json | 16 + apps/processor/nodemon.json | 5 - apps/processor/package.json | 43 - apps/processor/src/canVoteNow.ts | 32 - apps/processor/src/env.ts | 17 - apps/processor/src/index.ts | 226 - apps/processor/src/logger.ts | 25 - apps/processor/src/trpc.ts | 18 - apps/processor/tsconfig.json | 9 - apps/processor/tsup.config.ts | 8 - apps/web/.gitignore | 6 - apps/web/e2e/auth.setup.ts | 60 - apps/web/e2e/beforeLogin.test.ts | 173 - apps/web/e2e/fixtures/contoh-file-csv.csv | 4 - apps/web/e2e/main/candidate.spec.ts | 245 - apps/web/e2e/main/participant.spec.ts | 254 - apps/web/e2e/main/setttings.spec.ts | 109 - apps/web/package.json | 78 - apps/web/playwright.config.ts | 121 - apps/web/public/favicon.ico | Bin 4286 -> 0 bytes apps/web/public/sora.png | Bin 23975 -> 0 bytes .../src/components/DatePicker/DatePicker.tsx | 33 - .../components/DatePicker/chakra-support.css | 213 - apps/web/src/components/DatePicker/index.tsx | 1 - apps/web/src/components/InputImageBox.tsx | 150 - apps/web/src/components/Sidebar.tsx | 17 - .../pages/admin/PengaturanPerilaku.tsx | 145 - .../pages/admin/PengaturanWaktu.tsx | 178 - apps/web/src/env.mjs | 27 - apps/web/src/middleware.ts | 5 - apps/web/src/pages/_app.tsx | 25 - apps/web/src/pages/api/admin/kandidat.ts | 176 - apps/web/src/pages/api/auth/[...nextauth].ts | 5 - apps/web/src/pages/api/trpc/[trpc].ts | 13 - apps/web/src/pages/api/uploads/[filename].ts | 24 - apps/web/src/pages/index.tsx | 91 - apps/web/src/pages/kandidat/edit/[id].tsx | 231 - apps/web/src/pages/kandidat/index.tsx | 367 - apps/web/src/pages/kandidat/status.tsx | 191 - apps/web/src/pages/kandidat/tambah.tsx | 211 - apps/web/src/pages/login.tsx | 196 - apps/web/src/pages/pengaturan.tsx | 29 - apps/web/src/pages/peserta/csv.tsx | 234 - apps/web/src/pages/peserta/edit/[qrId].tsx | 174 - apps/web/src/pages/peserta/index.tsx | 688 - apps/web/src/pages/peserta/pdf.tsx | 170 - apps/web/src/pages/peserta/qr.tsx | 85 - apps/web/src/pages/peserta/tambah.tsx | 144 - apps/web/src/pages/register.tsx | 233 - apps/web/src/pages/statistik.tsx | 140 - apps/web/src/pages/ubah/nama.tsx | 148 - apps/web/src/pages/ubah/password.tsx | 167 - apps/web/src/styles/globals.css | 3 - apps/web/src/utils/api.ts | 33 - apps/web/src/utils/cors.ts | 26 - apps/web/tsconfig.json | 19 - ecosystem.config.js | 36 - package.json | 67 +- packages/api/env.mjs | 15 - packages/api/eslint.config.js | 9 + packages/api/index.ts | 18 - packages/api/package.json | 51 +- packages/api/src/index.ts | 33 + packages/api/src/root.ts | 8 +- packages/api/src/router/auth.ts | 144 +- packages/api/src/router/candidate.ts | 199 - packages/api/src/router/participant.ts | 319 - packages/api/src/router/post.ts | 39 + packages/api/src/router/settings.ts | 30 - packages/api/src/trpc.ts | 100 +- packages/api/tsconfig.json | 9 +- packages/auth/env.mjs | 24 - packages/auth/env.ts | 17 + packages/auth/eslint.config.js | 10 + packages/auth/index.ts | 3 - packages/auth/package.json | 46 +- packages/auth/src/auth-options.ts | 69 - packages/auth/src/config.ts | 35 + packages/auth/src/get-session.ts | 18 - packages/auth/src/index.rsc.ts | 21 + packages/auth/src/index.ts | 14 + packages/auth/tsconfig.json | 8 +- packages/db/eslint.config.js | 9 + packages/db/index.ts | 16 - packages/db/package.json | 46 +- packages/db/prisma/schema.prisma | 38 - packages/db/src/config.ts | 27 + packages/db/src/index.ts | 14 + packages/db/src/schema/_table.ts | 9 + packages/db/src/schema/auth.ts | 85 + packages/db/src/schema/post.ts | 14 + packages/db/tsconfig.json | 9 +- packages/settings/package.json | 24 - packages/settings/src/SettingsManager.ts | 73 - packages/settings/src/index.ts | 4 - packages/settings/src/utils.ts | 51 - packages/settings/tsconfig.json | 4 - packages/ui/.gitignore | 1 - packages/ui/Loading/index.tsx | 43 - packages/ui/Setting/index.tsx | 119 - packages/ui/Sidebar/LogoutButton.tsx | 124 - packages/ui/Sidebar/ModeToggler.tsx | 22 - packages/ui/Sidebar/Sidebar.tsx | 188 - .../fonts/NotoSansSundanese-Regular.ttf | Bin 39620 -> 0 bytes packages/ui/Sidebar/index.tsx | 1 - packages/ui/components.json | 17 + packages/ui/eslint.config.js | 11 + packages/ui/index.tsx | 3 - packages/ui/package.json | 71 +- packages/ui/src/button.tsx | 58 + packages/ui/src/dropdown-menu.tsx | 200 + packages/ui/src/form.tsx | 201 + packages/ui/src/index.ts | 6 + packages/ui/src/input.tsx | 24 + packages/ui/src/label.tsx | 25 + packages/ui/src/theme.tsx | 42 + packages/ui/src/toast.tsx | 31 + packages/ui/tailwind.config.ts | 11 + packages/ui/tsconfig.json | 10 +- packages/validators/eslint.config.js | 9 + packages/validators/package.json | 33 + packages/validators/src/index.ts | 6 + packages/validators/tsconfig.json | 9 + prettier.config.cjs | 36 - renovate.json | 13 - tooling/eslint/base.js | 77 + tooling/eslint/nextjs.js | 17 + tooling/eslint/package.json | 33 + tooling/eslint/react.js | 22 + tooling/eslint/tsconfig.json | 8 + tooling/eslint/types.d.ts | 58 + tooling/github/package.json | 3 + tooling/github/setup/action.yml | 17 + tooling/prettier/index.js | 36 + tooling/prettier/package.json | 24 + tooling/prettier/tsconfig.json | 8 + tooling/tailwind/base.ts | 48 + tooling/tailwind/eslint.config.js | 6 + tooling/tailwind/native.ts | 9 + tooling/tailwind/package.json | 31 + tooling/tailwind/tsconfig.json | 8 + tooling/tailwind/web.ts | 40 + tooling/typescript/base.json | 29 + tooling/typescript/internal-package.json | 11 + tooling/typescript/package.json | 8 + tsconfig.json | 21 - turbo.json | 73 +- turbo/generators/config.ts | 95 + .../generators/templates/eslint.config.js.hbs | 9 + turbo/generators/templates/package.json.hbs | 25 + turbo/generators/templates/tsconfig.json.hbs | 8 + vercel.json | 5 + yarn.lock | 15397 +++++++--------- 267 files changed, 9449 insertions(+), 19683 deletions(-) delete mode 100644 .circleci/config.yml delete mode 100644 .env.example delete mode 100644 .eslintrc.js delete mode 100644 .github/actions/yarn-nm-install/action.yml delete mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/codeql.yml delete mode 100644 .github/workflows/e2e.yml delete mode 100644 .npmrc create mode 100644 apps/auth-proxy/.env.example create mode 100644 apps/auth-proxy/README.md create mode 100644 apps/auth-proxy/eslint.config.js create mode 100644 apps/auth-proxy/package.json create mode 100644 apps/auth-proxy/routes/[...auth].ts create mode 100644 apps/auth-proxy/tsconfig.json delete mode 100644 apps/desktop/attendance/.eslintignore delete mode 100644 apps/desktop/attendance/.eslintrc.json delete mode 100644 apps/desktop/attendance/.gitignore delete mode 100644 apps/desktop/attendance/.prettierignore delete mode 100644 apps/desktop/attendance/build/entitlements.mac.plist delete mode 100644 apps/desktop/attendance/build/icon.icns delete mode 100644 apps/desktop/attendance/build/icon.ico delete mode 100644 apps/desktop/attendance/build/icon.png delete mode 100644 apps/desktop/attendance/build/notarize.js delete mode 100644 apps/desktop/attendance/electron-builder.yml delete mode 100644 apps/desktop/attendance/electron.vite.config.ts delete mode 100644 apps/desktop/attendance/package.json delete mode 100644 apps/desktop/attendance/resources/icon.png delete mode 100644 apps/desktop/attendance/src/main/index.ts delete mode 100644 apps/desktop/attendance/src/preload/index.d.ts delete mode 100644 apps/desktop/attendance/src/preload/index.ts delete mode 100644 apps/desktop/attendance/src/renderer/index.html delete mode 100644 apps/desktop/attendance/src/renderer/src/App.tsx delete mode 100644 apps/desktop/attendance/src/renderer/src/UpperProvider.tsx delete mode 100644 apps/desktop/attendance/src/renderer/src/components/PreScan/CantAttend.tsx delete mode 100644 apps/desktop/attendance/src/renderer/src/components/PreScan/ErrorOccured.tsx delete mode 100644 apps/desktop/attendance/src/renderer/src/components/Scanner/NormalScanner.tsx delete mode 100644 apps/desktop/attendance/src/renderer/src/components/Scanner/ScanningError.tsx delete mode 100644 apps/desktop/attendance/src/renderer/src/components/Scanner/SuccessScan.tsx delete mode 100644 apps/desktop/attendance/src/renderer/src/components/Scanner/index.tsx delete mode 100644 apps/desktop/attendance/src/renderer/src/context/AppSetting.tsx delete mode 100644 apps/desktop/attendance/src/renderer/src/context/SettingContext.tsx delete mode 100644 apps/desktop/attendance/src/renderer/src/env.d.ts delete mode 100644 apps/desktop/attendance/src/renderer/src/main.tsx delete mode 100644 apps/desktop/attendance/src/renderer/src/routes/Main.tsx delete mode 100644 apps/desktop/attendance/src/renderer/src/styles/components/Scanner.module.css delete mode 100644 apps/desktop/attendance/src/renderer/src/utils/trpc.ts delete mode 100644 apps/desktop/attendance/tsconfig.json delete mode 100644 apps/desktop/attendance/tsconfig.node.json delete mode 100644 apps/desktop/attendance/tsconfig.web.json delete mode 100644 apps/desktop/chooser/.editorconfig delete mode 100644 apps/desktop/chooser/.eslintignore delete mode 100644 apps/desktop/chooser/.eslintrc.json delete mode 100644 apps/desktop/chooser/.gitignore delete mode 100644 apps/desktop/chooser/.prettierignore delete mode 100644 apps/desktop/chooser/build/entitlements.mac.plist delete mode 100644 apps/desktop/chooser/build/icon.icns delete mode 100644 apps/desktop/chooser/build/icon.ico delete mode 100644 apps/desktop/chooser/build/icon.png delete mode 100644 apps/desktop/chooser/build/notarize.js delete mode 100644 apps/desktop/chooser/electron-builder.yml delete mode 100644 apps/desktop/chooser/electron.vite.config.ts delete mode 100644 apps/desktop/chooser/package.json delete mode 100644 apps/desktop/chooser/resources/icon.png delete mode 100644 apps/desktop/chooser/src/main/index.ts delete mode 100644 apps/desktop/chooser/src/main/keyboard.ts delete mode 100644 apps/desktop/chooser/src/preload/index.d.ts delete mode 100644 apps/desktop/chooser/src/preload/index.ts delete mode 100644 apps/desktop/chooser/src/renderer/index.html delete mode 100644 apps/desktop/chooser/src/renderer/src/App.tsx delete mode 100644 apps/desktop/chooser/src/renderer/src/UpperProvider.tsx delete mode 100644 apps/desktop/chooser/src/renderer/src/components/AfterVote/BerhasilMemilihDanCapJari.tsx delete mode 100644 apps/desktop/chooser/src/renderer/src/components/PreScan/CantVote.tsx delete mode 100644 apps/desktop/chooser/src/renderer/src/components/PreScan/ErrorOccured.tsx delete mode 100644 apps/desktop/chooser/src/renderer/src/components/PreScan/InvalidCandidate.tsx delete mode 100644 apps/desktop/chooser/src/renderer/src/components/Scanner/NormalScanner.tsx delete mode 100644 apps/desktop/chooser/src/renderer/src/components/Scanner/index.tsx delete mode 100644 apps/desktop/chooser/src/renderer/src/components/UniversalErrorHandler.tsx delete mode 100644 apps/desktop/chooser/src/renderer/src/context/AppSetting.tsx delete mode 100644 apps/desktop/chooser/src/renderer/src/context/ParticipantContext.tsx delete mode 100644 apps/desktop/chooser/src/renderer/src/context/SettingContext.tsx delete mode 100644 apps/desktop/chooser/src/renderer/src/env.d.ts delete mode 100644 apps/desktop/chooser/src/renderer/src/main.tsx delete mode 100644 apps/desktop/chooser/src/renderer/src/routes/Main.tsx delete mode 100644 apps/desktop/chooser/src/renderer/src/routes/Vote.tsx delete mode 100644 apps/desktop/chooser/src/renderer/src/styles/components/Scanner.module.css delete mode 100644 apps/desktop/chooser/src/renderer/src/utils/trpc.ts delete mode 100644 apps/desktop/chooser/tsconfig.json delete mode 100644 apps/desktop/chooser/tsconfig.node.json delete mode 100644 apps/desktop/chooser/tsconfig.web.json rename apps/{web => nextjs}/README.md (97%) create mode 100644 apps/nextjs/eslint.config.js rename apps/{web/next.config.mjs => nextjs/next.config.js} (56%) create mode 100644 apps/nextjs/package.json create mode 100644 apps/nextjs/postcss.config.cjs create mode 100644 apps/nextjs/public/favicon.ico create mode 100644 apps/nextjs/public/t3-icon.svg create mode 100644 apps/nextjs/src/app/_components/auth-showcase.tsx create mode 100644 apps/nextjs/src/app/_components/posts.tsx create mode 100644 apps/nextjs/src/app/api/auth/[...nextauth]/route.ts create mode 100644 apps/nextjs/src/app/api/trpc/[trpc]/route.ts create mode 100644 apps/nextjs/src/app/globals.css create mode 100644 apps/nextjs/src/app/layout.tsx create mode 100644 apps/nextjs/src/app/page.tsx create mode 100644 apps/nextjs/src/env.ts create mode 100644 apps/nextjs/src/middleware.ts create mode 100644 apps/nextjs/src/trpc/react.tsx create mode 100644 apps/nextjs/src/trpc/server.ts create mode 100644 apps/nextjs/tailwind.config.ts create mode 100644 apps/nextjs/tsconfig.json delete mode 100644 apps/processor/nodemon.json delete mode 100644 apps/processor/package.json delete mode 100644 apps/processor/src/canVoteNow.ts delete mode 100644 apps/processor/src/env.ts delete mode 100644 apps/processor/src/index.ts delete mode 100644 apps/processor/src/logger.ts delete mode 100644 apps/processor/src/trpc.ts delete mode 100644 apps/processor/tsconfig.json delete mode 100644 apps/processor/tsup.config.ts delete mode 100644 apps/web/.gitignore delete mode 100644 apps/web/e2e/auth.setup.ts delete mode 100644 apps/web/e2e/beforeLogin.test.ts delete mode 100644 apps/web/e2e/fixtures/contoh-file-csv.csv delete mode 100644 apps/web/e2e/main/candidate.spec.ts delete mode 100644 apps/web/e2e/main/participant.spec.ts delete mode 100644 apps/web/e2e/main/setttings.spec.ts delete mode 100644 apps/web/package.json delete mode 100644 apps/web/playwright.config.ts delete mode 100644 apps/web/public/favicon.ico delete mode 100644 apps/web/public/sora.png delete mode 100644 apps/web/src/components/DatePicker/DatePicker.tsx delete mode 100644 apps/web/src/components/DatePicker/chakra-support.css delete mode 100644 apps/web/src/components/DatePicker/index.tsx delete mode 100644 apps/web/src/components/InputImageBox.tsx delete mode 100644 apps/web/src/components/Sidebar.tsx delete mode 100644 apps/web/src/components/pages/admin/PengaturanPerilaku.tsx delete mode 100644 apps/web/src/components/pages/admin/PengaturanWaktu.tsx delete mode 100644 apps/web/src/env.mjs delete mode 100644 apps/web/src/middleware.ts delete mode 100644 apps/web/src/pages/_app.tsx delete mode 100644 apps/web/src/pages/api/admin/kandidat.ts delete mode 100644 apps/web/src/pages/api/auth/[...nextauth].ts delete mode 100644 apps/web/src/pages/api/trpc/[trpc].ts delete mode 100644 apps/web/src/pages/api/uploads/[filename].ts delete mode 100644 apps/web/src/pages/index.tsx delete mode 100644 apps/web/src/pages/kandidat/edit/[id].tsx delete mode 100644 apps/web/src/pages/kandidat/index.tsx delete mode 100644 apps/web/src/pages/kandidat/status.tsx delete mode 100644 apps/web/src/pages/kandidat/tambah.tsx delete mode 100644 apps/web/src/pages/login.tsx delete mode 100644 apps/web/src/pages/pengaturan.tsx delete mode 100644 apps/web/src/pages/peserta/csv.tsx delete mode 100644 apps/web/src/pages/peserta/edit/[qrId].tsx delete mode 100644 apps/web/src/pages/peserta/index.tsx delete mode 100644 apps/web/src/pages/peserta/pdf.tsx delete mode 100644 apps/web/src/pages/peserta/qr.tsx delete mode 100644 apps/web/src/pages/peserta/tambah.tsx delete mode 100644 apps/web/src/pages/register.tsx delete mode 100644 apps/web/src/pages/statistik.tsx delete mode 100644 apps/web/src/pages/ubah/nama.tsx delete mode 100644 apps/web/src/pages/ubah/password.tsx delete mode 100644 apps/web/src/styles/globals.css delete mode 100644 apps/web/src/utils/api.ts delete mode 100644 apps/web/src/utils/cors.ts delete mode 100644 apps/web/tsconfig.json delete mode 100644 ecosystem.config.js delete mode 100644 packages/api/env.mjs create mode 100644 packages/api/eslint.config.js delete mode 100644 packages/api/index.ts create mode 100644 packages/api/src/index.ts delete mode 100644 packages/api/src/router/candidate.ts delete mode 100644 packages/api/src/router/participant.ts create mode 100644 packages/api/src/router/post.ts delete mode 100644 packages/api/src/router/settings.ts delete mode 100644 packages/auth/env.mjs create mode 100644 packages/auth/env.ts create mode 100644 packages/auth/eslint.config.js delete mode 100644 packages/auth/index.ts delete mode 100644 packages/auth/src/auth-options.ts create mode 100644 packages/auth/src/config.ts delete mode 100644 packages/auth/src/get-session.ts create mode 100644 packages/auth/src/index.rsc.ts create mode 100644 packages/auth/src/index.ts create mode 100644 packages/db/eslint.config.js delete mode 100644 packages/db/index.ts delete mode 100644 packages/db/prisma/schema.prisma create mode 100644 packages/db/src/config.ts create mode 100644 packages/db/src/index.ts create mode 100644 packages/db/src/schema/_table.ts create mode 100644 packages/db/src/schema/auth.ts create mode 100644 packages/db/src/schema/post.ts delete mode 100644 packages/settings/package.json delete mode 100644 packages/settings/src/SettingsManager.ts delete mode 100644 packages/settings/src/index.ts delete mode 100644 packages/settings/src/utils.ts delete mode 100644 packages/settings/tsconfig.json delete mode 100644 packages/ui/.gitignore delete mode 100644 packages/ui/Loading/index.tsx delete mode 100644 packages/ui/Setting/index.tsx delete mode 100644 packages/ui/Sidebar/LogoutButton.tsx delete mode 100644 packages/ui/Sidebar/ModeToggler.tsx delete mode 100644 packages/ui/Sidebar/Sidebar.tsx delete mode 100644 packages/ui/Sidebar/fonts/NotoSansSundanese-Regular.ttf delete mode 100644 packages/ui/Sidebar/index.tsx create mode 100644 packages/ui/components.json create mode 100644 packages/ui/eslint.config.js delete mode 100644 packages/ui/index.tsx create mode 100644 packages/ui/src/button.tsx create mode 100644 packages/ui/src/dropdown-menu.tsx create mode 100644 packages/ui/src/form.tsx create mode 100644 packages/ui/src/index.ts create mode 100644 packages/ui/src/input.tsx create mode 100644 packages/ui/src/label.tsx create mode 100644 packages/ui/src/theme.tsx create mode 100644 packages/ui/src/toast.tsx create mode 100644 packages/ui/tailwind.config.ts create mode 100644 packages/validators/eslint.config.js create mode 100644 packages/validators/package.json create mode 100644 packages/validators/src/index.ts create mode 100644 packages/validators/tsconfig.json delete mode 100644 prettier.config.cjs delete mode 100644 renovate.json create mode 100644 tooling/eslint/base.js create mode 100644 tooling/eslint/nextjs.js create mode 100644 tooling/eslint/package.json create mode 100644 tooling/eslint/react.js create mode 100644 tooling/eslint/tsconfig.json create mode 100644 tooling/eslint/types.d.ts create mode 100644 tooling/github/package.json create mode 100644 tooling/github/setup/action.yml create mode 100644 tooling/prettier/index.js create mode 100644 tooling/prettier/package.json create mode 100644 tooling/prettier/tsconfig.json create mode 100644 tooling/tailwind/base.ts create mode 100644 tooling/tailwind/eslint.config.js create mode 100644 tooling/tailwind/native.ts create mode 100644 tooling/tailwind/package.json create mode 100644 tooling/tailwind/tsconfig.json create mode 100644 tooling/tailwind/web.ts create mode 100644 tooling/typescript/base.json create mode 100644 tooling/typescript/internal-package.json create mode 100644 tooling/typescript/package.json delete mode 100644 tsconfig.json create mode 100644 turbo/generators/config.ts create mode 100644 turbo/generators/templates/eslint.config.js.hbs create mode 100644 turbo/generators/templates/package.json.hbs create mode 100644 turbo/generators/templates/tsconfig.json.hbs create mode 100644 vercel.json diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 836862ca..00000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,165 +0,0 @@ -version: 2.1 - -orbs: - node: circleci/node@5.0.2 - -executors: - linux: &linux-executor - machine: - image: ubuntu-2004:2023.04.2 - win: &win-executor - machine: - image: windows-server-2022-gui:current - resource_class: windows.medium - shell: powershell.exe -ExecutionPolicy Bypass - -jobs: - desktop-builder: - parameters: - os: - type: executor - executor: << parameters.os >> - steps: - - checkout - - # initiate workspace - - attach_workspace: - at: . - - # Initiate node js on linux - - when: - condition: - equal: [*linux-executor, << parameters.os >>] - steps: - - node/install: - node-version: "18.15.0" - - # Initiate node js on windows - - when: - condition: - equal: [*win-executor, << parameters.os >>] - steps: - - run: choco install wget -y - - run: - command: wget https://nodejs.org/dist/v18.15.0/node-v18.15.0-x86.msi -P C:\Users\circleci\Downloads\ - shell: cmd.exe - - run: MsiExec.exe /i C:\Users\circleci\Downloads\node-v18.15.0-x86.msi /qn - - run: - command: | - Start-Process powershell -verb runAs -Args "-start GeneralProfile" - nvm install 18.15.0 - nvm use 18.15.0 - - - run: node --version - - run: - name: Using latest yarn - command: | - corepack enable - corepack prepare yarn@stable --activate - - run: yarn -v - - restore_cache: - name: Restore yarn package cache - keys: - - yarn-packages-{{ checksum "yarn.lock" }} - - run: - name: Install all dependencies - command: | - yarn install - - save_cache: - name: Save Yarn Package Cache - key: yarn-packages-{{ checksum "yarn.lock" }} - paths: - - .yarn/cache - - .yarn/releases - - .yarn/install-state.gz - - - run: - name: Test Code Linting - command: | - yarn lint - - - when: - condition: - equal: [*linux-executor, << parameters.os >>] - steps: - - run: - name: Build linux based desktop app - command: | - yarn db:generate - yarn build-desktop:linux - tar -czvf ./apps/desktop/chooser/dist/sora-desktop-linux-unpacked.tar.gz ./apps/desktop/chooser/dist/linux-unpacked - tar -czvf ./apps/desktop/attendance/dist/absensi-desktop-linux-unpacked.tar.gz ./apps/desktop/attendance/dist/linux-unpacked - mkdir ./apps/desktop/chooser/dist-desktop - mkdir ./apps/desktop/attendance/dist-desktop - - cp ./apps/desktop/chooser/dist/*.tar.gz ./apps/desktop/chooser/dist-desktop - cp ./apps/desktop/chooser/dist/*.deb ./apps/desktop/chooser/dist-desktop - cp ./apps/desktop/chooser/dist/*.AppImage ./apps/desktop/chooser/dist-desktop - - cp ./apps/desktop/attendance/dist/*.tar.gz ./apps/desktop/attendance/dist-desktop - cp ./apps/desktop/attendance/dist/*.deb ./apps/desktop/attendance/dist-desktop - cp ./apps/desktop/attendance/dist/*.AppImage ./apps/desktop/attendance/dist-desktop - - mkdir dist - cp ./apps/desktop/chooser/dist-desktop/* ./dist - cp ./apps/desktop/attendance/dist-desktop/* ./dist - - - when: - condition: - equal: [*win-executor, << parameters.os >>] - steps: - - run: - name: Build windows desktop app - command: | - yarn db:generate - yarn build-desktop:win - Compress-Archive -Path .\apps\desktop\chooser\dist\win-unpacked -Destination .\apps\desktop\chooser\dist\sora-desktop-win-unpacked.zip - Compress-Archive -Path .\apps\desktop\attendance\dist\win-unpacked -Destination .\apps\desktop\attendance\dist\absensi-desktop-win-unpacked.zip - mkdir .\apps\desktop\chooser\dist-desktop - mkdir .\apps\desktop\attendance\dist-desktop - - cp .\apps\desktop\chooser\dist\*.zip .\apps\desktop\chooser\dist-desktop - cp .\apps\desktop\chooser\dist\*.exe .\apps\desktop\chooser\dist-desktop - - cp .\apps\desktop\attendance\dist\*.zip .\apps\desktop\attendance\dist-desktop - cp .\apps\desktop\attendance\dist\*.exe .\apps\desktop\attendance\dist-desktop - - mkdir dist - cp .\apps\desktop\chooser\dist-desktop\* .\dist\ - cp .\apps\desktop\attendance\dist-desktop\* .\dist\ - - - persist_to_workspace: - root: . - paths: - - dist - - publish-github-release: - docker: - - image: cimg/go:1.20.3 - steps: - - attach_workspace: - at: . - - run: - name: "Publish Release on GitHub" - command: | - go install github.com/tcnksm/ghr@latest - ghr -body "Note: karena belum ada code signing yang tersedia, maka pada saat instalasi dimohon untuk mematikan antivirus yang ada." -n "Release aplikasi desktop versi (v-.-.-)" -t ${GITHUB_TOKEN} -u ${CIRCLE_PROJECT_USERNAME} -r ${CIRCLE_PROJECT_REPONAME} -draft -c ${CIRCLE_SHA1} ${CIRCLE_TAG} dist/ - -workflows: - all-builds: - jobs: - - desktop-builder: - matrix: - parameters: - os: [linux, win] - filters: - tags: - only: /^desktop-v\d+\.\d+\.\d+/ - - publish-github-release: - requires: - - desktop-builder - filters: - branches: - ignore: /.*/ - tags: - only: /^desktop-v\d+\.\d+\.\d+/ diff --git a/.env.example b/.env.example deleted file mode 100644 index e276d68a..00000000 --- a/.env.example +++ /dev/null @@ -1,26 +0,0 @@ -# Since the ".env" file is gitignored, you can use the ".env.example" file to -# build a new ".env" file when you clone the repo. Keep this file up-to-date -# when you add new variables to `.env`. - -# This file will be committed to version control, so make sure not to have any -# secrets in it. If you are cloning this repo, create a copy of this file named -# ".env" and populate it with your secrets. - -# Prisma -# https://www.prisma.io/docs/reference/database-reference/connection-urls#env -DATABASE_URL="mysql://usrnm:pw@localhost:3306/sora" - -# Next Auth -# You can generate a new secret on the command line with: -# openssl rand -base64 32 -# https://next-auth.js.org/configuration/options#secret -NEXTAUTH_SECRET="" -NEXTAUTH_URL="http://localhost:3000" - -# RabbitMQ -# This env variable will connect to rabbitmq instance -AMQP_URL="amqp://localhost" - -# TRPC Endpoint -# This env variable will tell the vote processor where is the trpc endpoint -TRPC_URL="http://localhost:3000/api/trpc" \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 80ef52b2..00000000 --- a/.eslintrc.js +++ /dev/null @@ -1,22 +0,0 @@ -/** @type {import("eslint").Linter.Config} */ -const config = { - root: true, - extends: ["@sora/eslint-config"], // uses the config in `packages/config/eslint` - parser: "@typescript-eslint/parser", - parserOptions: { - ecmaVersion: "latest", - tsconfigRootDir: __dirname, - project: [ - "./tsconfig.json", - "./apps/*/tsconfig.json", - "./packages/*/tsconfig.json", - ], - }, - settings: { - next: { - rootDir: ["apps/nextjs"], - }, - }, -}; - -module.exports = config; diff --git a/.github/actions/yarn-nm-install/action.yml b/.github/actions/yarn-nm-install/action.yml deleted file mode 100644 index 9ec98536..00000000 --- a/.github/actions/yarn-nm-install/action.yml +++ /dev/null @@ -1,59 +0,0 @@ -# https://github.com/belgattitude/nextjs-monorepo-example/blob/0dec79c0d8768361c0f13cf8e20398d149b0e5a9/.github/actions/yarn-nm-install/action.yml - -name: "Monorepo install (yarn)" -description: "Run yarn install with node_modules linker and cache enabled" -inputs: - enable-corepack: - description: "Enable corepack" - required: false - default: "true" - - playwright-skip-browser-download: - description: "Avoid playwright to download browsers automatically" - required: false - default: "1" - -runs: - using: "composite" - - steps: - - name: ⚙️ Enable Corepack - if: ${{ inputs.enable-corepack }} == 'true' - shell: bash - run: corepack enable - - - name: ⚙️ Expose yarn config as "$GITHUB_OUTPUT" - id: yarn-config - shell: bash - run: | - echo "CACHE_FOLDER=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT - # Yarn rotates the downloaded cache archives, @see https://github.com/actions/setup-node/issues/325 - # Yarn cache is also reusable between arch and os. - - name: ♻️ Restore yarn cache - uses: actions/cache@v3 - id: yarn-download-cache - with: - path: ${{ steps.yarn-config.outputs.CACHE_FOLDER }} - key: yarn-download-cache-${{ hashFiles('yarn.lock') }} - restore-keys: | - yarn-download-cache- - # Save install_state (invalidated on yarn.lock changes) - - name: ♻️ Restore yarn install state - id: yarn-install-state-cache - uses: actions/cache@v3 - with: - path: .yarn/ci-cache/ - key: ${{ runner.os }}-yarn-install-state-cache-${{ hashFiles('yarn.lock', '.yarnrc.yml') }} - - - name: 📥 Install dependencies - shell: bash - run: | - yarn install --immutable --inline-builds - env: - # CI optimizations. Overrides yarnrc.yml options (or their defaults) in the CI action. - YARN_ENABLE_GLOBAL_CACHE: "false" # Use local cache folder to keep downloaded archives - YARN_NM_MODE: "hardlinks-local" # Hardlinks-(local|global) reduces io / node_modules size - YARN_INSTALL_STATE_PATH: .yarn/ci-cache/install-state.gz # Very small speedup when lock does not change - # Other environment variables - HUSKY: "0" # By default do not run HUSKY install - PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: ${{ inputs.playwright-skip-browser-download }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 01ef4ab8..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Lint, TS, Prettier Check -on: [pull_request, push] - -env: - SKIP_ENV_VALIDATION: true - -jobs: - ci: - runs-on: ubuntu-latest - name: Mengecek apakah file aman dari segi typing dan linting, juga prettier - - steps: - - name: Checkout - uses: actions/checkout@v3 - - - name: Install Node.js - uses: actions/setup-node@v3 - with: - node-version: 18 - - - name: Install all dependencies - uses: ./.github/actions/yarn-nm-install - - - name: Test Prettier - run: yarn format:check - - - name: Test Code Linting - run: yarn lint - - - name: Test Typing - run: yarn type-check diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index 139730c8..00000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,75 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" - -on: - push: - branches: ["main"] - pull_request: - # The branches below must be a subset of the branches above - branches: ["main"] - schedule: - - cron: "19 20 * * 6" - -jobs: - analyze: - name: Analyze - runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: ["javascript"] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] - # Use only 'java' to analyze code written in Java, Kotlin or both - # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both - # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support - - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - - # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality - - # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v2 - - # ℹ️ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - - # If the Autobuild fails above, remove it and uncomment the following three lines. - # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. - - # - run: | - # echo "Run, Build Application using script" - # ./location_of_script_within_repo/buildscript.sh - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 - with: - category: "/language:${{matrix.language}}" diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml deleted file mode 100644 index f8176b56..00000000 --- a/.github/workflows/e2e.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: End to End testing for web -on: [pull_request, push] - -env: - DATABASE_URL: "mysql://root:password@localhost:3306/sora-test-e2e" - NEXTAUTH_SECRET: "askdhsakjndkasudasludaksjd" - NEXTAUTH_URL: "http://localhost:3000" - AMQP_URL: "amqp://localhost" - -jobs: - e2e: - name: End to End testing web interface only - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v3 - - - name: Install Node.js - uses: actions/setup-node@v3 - with: - node-version: 18 - - - name: Install all dependencies - uses: ./.github/actions/yarn-nm-install - - - name: Pull MariaDB image - run: docker pull mariadb - - - name: Start MariaDB container - run: | - docker run -d --name mariadb_container -p 3306:3306 -e MYSQL_ROOT_PASSWORD=password mariadb - - - name: Wait for MariaDB port - run: | - until nc -z localhost 3306; do sleep 1; done - - - name: Push prisma schema to MariaDB - run: yarn e2e:db-push - - - name: Build web - run: yarn turbo run build --filter=@sora/web - - - name: Install playwright browser - run: npx playwright install --with-deps - - - name: Run E2E - run: yarn e2e - - - name: Archive test results - if: always() - uses: actions/upload-artifact@v3 - with: - name: Test Results - path: apps/web/test-results - - - name: Archive playwright report - if: always() - uses: actions/upload-artifact@v3 - with: - name: Playwright Report - path: apps/web/playwright-report diff --git a/.gitignore b/.gitignore index f3dc17f8..2b39dec4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. +# yarn +.yarn/ + # dependencies node_modules .pnp @@ -8,15 +11,25 @@ node_modules # testing coverage -# database -**/prisma/db.sqlite -**/prisma/db.sqlite-journal - # next.js .next/ out/ next-env.d.ts +# nitro +.nitro/ +.output/ + +# expo +.expo/ +expo-env.d.ts +apps/expo/.gitignore +apps/expo/ios +apps/expo/android + +# production +build + # misc .DS_Store *.pem @@ -30,27 +43,13 @@ yarn-error.log* # local env files .env .env*.local -.env.test # vercel .vercel # typescript *.tsbuildinfo +dist/ # turbo .turbo - -.pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/sdks -!.yarn/versions - -**/public/uploads -**/*.log -**/dist - -playwright/.auth \ No newline at end of file diff --git a/.npmrc b/.npmrc deleted file mode 100644 index 4be8ed0f..00000000 --- a/.npmrc +++ /dev/null @@ -1,13 +0,0 @@ -# In order to cache Prisma correctly -public-hoist-pattern[]=*prisma* - -# FIXME: @prisma/client is required by the @acme/auth, -# but we don't want it installed there since it's already -# installed in the @acme/db package -strict-peer-dependencies=false - -# Prevent pnpm from adding the "workspace:"" prefix to local -# packages as it causes issues with manypkg -# @link https://pnpm.io/npmrc#prefer-workspace-packages -save-workspace-protocol=false -prefer-workspace-packages=true diff --git a/.nvmrc b/.nvmrc index 25bf17fc..d5908b99 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -18 \ No newline at end of file +20.12 \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json index b9580949..3606d874 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,8 +1,9 @@ { "recommendations": [ - "esbenp.prettier-vscode", "dbaeumer.vscode-eslint", - "Prisma.prisma", - "yoavbls.pretty-ts-errors" + "expo.vscode-expo-tools", + "esbenp.prettier-vscode", + "yoavbls.pretty-ts-errors", + "bradlc.vscode-tailwindcss" ] } diff --git a/.vscode/launch.json b/.vscode/launch.json index 66ab8349..5fcd8452 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,19 +2,11 @@ "version": "0.2.0", "configurations": [ { - "name": "Development mode web interface", + "name": "Next.js", "type": "node-terminal", "request": "launch", - "command": "yarn dev", - "cwd": "${workspaceFolder}/apps/web/", - "skipFiles": ["/**"] - }, - { - "name": "E2E web interface w/ ui", - "type": "node-terminal", - "request": "launch", - "command": "yarn e2e:ui", - "cwd": "${workspaceFolder}/apps/web/", + "command": "pnpm dev", + "cwd": "${workspaceFolder}/apps/nextjs/", "skipFiles": ["/**"] } ] diff --git a/.vscode/settings.json b/.vscode/settings.json index 2dc1c1bf..ea77a68b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,11 +1,25 @@ { + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true, "eslint.rules.customizations": [{ "rule": "*", "severity": "warn" }], + "eslint.experimental.useFlatConfig": true, "eslint.workingDirectories": [ { "pattern": "apps/*/" }, - { "pattern": "packages/*/" } + { "pattern": "packages/*/" }, + { "pattern": "tooling/*/" } ], - "typescript.tsdk": "node_modules/typescript/lib", - "typescript.enablePromptUseWorkspaceTsdk": true + "tailwindCSS.experimental.classRegex": [ + ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], + ["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"] + ], + "tailwindCSS.experimental.configFile": "./tooling/tailwind/web.ts", + "typescript.enablePromptUseWorkspaceTsdk": true, + "typescript.preferences.autoImportFileExcludePatterns": [ + "next/router.d.ts", + "next/dist/client/router.d.ts" + ], + "typescript.tsdk": "node_modules/typescript/lib" } diff --git a/.yarnrc.yml b/.yarnrc.yml index 7f3d03fd..3186f3f0 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1 +1 @@ -nodeLinker: "node-modules" +nodeLinker: node-modules diff --git a/apps/auth-proxy/.env.example b/apps/auth-proxy/.env.example new file mode 100644 index 00000000..bdb4d554 --- /dev/null +++ b/apps/auth-proxy/.env.example @@ -0,0 +1,7 @@ + +AUTH_SECRET="" +AUTH_DISCORD_ID="" +AUTH_DISCORD_SECRET="" +AUTH_REDIRECT_PROXY_URL="" + +NITRO_PRESET="vercel_edge" \ No newline at end of file diff --git a/apps/auth-proxy/README.md b/apps/auth-proxy/README.md new file mode 100644 index 00000000..d8e48587 --- /dev/null +++ b/apps/auth-proxy/README.md @@ -0,0 +1,22 @@ +# Auth Proxy + +This is a simple proxy server that enables OAuth authentication for preview environments. + +## Setup + +Deploy it somewhere (Vercel is a one-click, zero-config option) and set the following environment variables: + +- `AUTH_DISCORD_ID` - The Discord OAuth client ID +- `AUTH_DISCORD_SECRET` - The Discord OAuth client secret +- `AUTH_REDIRECT_PROXY_URL` - The URL of this proxy server +- `AUTH_SECRET` - Your secret + +Make sure the `AUTH_SECRET` and `AUTH_REDIRECT_PROXY_URL` match the values set for the main application's deployment for preview environments, and that you're using the same OAuth credentials for the proxy and the application's preview environment. +`AUTH_REDIRECT_PROXY_URL` should only be set for the main application's preview environment. Do not set it for the production environment. +The lines below shows what values should match eachother in both deployments. + +![Environment variables setup](https://github.com/t3-oss/create-t3-turbo/assets/51714798/5fadd3f5-f705-459a-82ab-559a3df881d0) + +For providers that require an origin and a redirect URL, set them to `{AUTH_REDIRECT_PROXY_URL}` and `{AUTH_REDIRECT_PROXY_URL}/callback/{provider}` accordingly. + +![Google credentials setup](https://github.com/ahkhanjani/create-t3-turbo/assets/72540492/eaa88685-6fc2-4c23-b7ac-737eb172fa0e) diff --git a/apps/auth-proxy/eslint.config.js b/apps/auth-proxy/eslint.config.js new file mode 100644 index 00000000..193bddcd --- /dev/null +++ b/apps/auth-proxy/eslint.config.js @@ -0,0 +1,9 @@ +import baseConfig from "@sora-vp/eslint-config/base"; + +/** @type {import('typescript-eslint').Config} */ +export default [ + { + ignores: [".nitro/**", ".output/**"], + }, + ...baseConfig, +]; diff --git a/apps/auth-proxy/package.json b/apps/auth-proxy/package.json new file mode 100644 index 00000000..ffe83d55 --- /dev/null +++ b/apps/auth-proxy/package.json @@ -0,0 +1,29 @@ +{ + "name": "@sora-vp/auth-proxy", + "private": true, + "type": "module", + "scripts": { + "build": "nitro build", + "clean": "rm -rf .turbo node_modules", + "dev": "nitro dev --port 3001", + "lint": "eslint", + "format": "prettier --check . --ignore-path ../../.gitignore", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@auth/core": "0.30.0" + }, + "devDependencies": { + "@sora-vp/eslint-config": "*", + "@sora-vp/prettier-config": "*", + "@sora-vp/tailwind-config": "*", + "@sora-vp/tsconfig": "*", + "@types/node": "^20.12.9", + "eslint": "^9.2.0", + "h3": "^1.11.1", + "nitropack": "^2.9.6", + "prettier": "^3.2.5", + "typescript": "^5.4.5" + }, + "prettier": "@sora-vp/prettier-config" +} diff --git a/apps/auth-proxy/routes/[...auth].ts b/apps/auth-proxy/routes/[...auth].ts new file mode 100644 index 00000000..f3f737be --- /dev/null +++ b/apps/auth-proxy/routes/[...auth].ts @@ -0,0 +1,17 @@ +import { Auth } from "@auth/core"; +import Discord from "@auth/core/providers/discord"; +import { eventHandler, toWebRequest } from "h3"; + +export default eventHandler(async (event) => + Auth(toWebRequest(event), { + secret: process.env.AUTH_SECRET, + trustHost: !!process.env.VERCEL, + redirectProxyUrl: process.env.AUTH_REDIRECT_PROXY_URL, + providers: [ + Discord({ + clientId: process.env.AUTH_DISCORD_ID, + clientSecret: process.env.AUTH_DISCORD_SECRET, + }), + ], + }), +); diff --git a/apps/auth-proxy/tsconfig.json b/apps/auth-proxy/tsconfig.json new file mode 100644 index 00000000..cd361161 --- /dev/null +++ b/apps/auth-proxy/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@sora-vp/tsconfig/base.json", + "include": ["routes"] +} diff --git a/apps/desktop/attendance/.eslintignore b/apps/desktop/attendance/.eslintignore deleted file mode 100644 index a6f34fea..00000000 --- a/apps/desktop/attendance/.eslintignore +++ /dev/null @@ -1,4 +0,0 @@ -node_modules -dist -out -.gitignore diff --git a/apps/desktop/attendance/.eslintrc.json b/apps/desktop/attendance/.eslintrc.json deleted file mode 100644 index 4e752de5..00000000 --- a/apps/desktop/attendance/.eslintrc.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "root": true, - "env": { - "browser": true, - "commonjs": true, - "es2021": true, - "node": true - }, - "extends": [ - "eslint:recommended", - "plugin:react/recommended", - "plugin:@typescript-eslint/recommended" - ], - "settings": { - "react": { - "version": "detect" - } - }, - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": "latest", - "sourceType": "module", - "ecmaFeatures": { - "jsx": true - } - }, - "plugins": ["react", "prettier", "@typescript-eslint"], - "rules": { - "react/react-in-jsx-scope": "off", - "react/jsx-uses-react": "off", - "@typescript-eslint/no-var-requires": "off", - "@typescript-eslint/ban-ts-comment": [ - "error", - { "ts-ignore": "allow-with-description" } - ], - "react/jsx-filename-extension": [ - 1, - { "extensions": [".js", ".jsx", ".ts", ".tsx"] } - ] - }, - "overrides": [ - { - "files": ["*.js"], - "rules": { - "@typescript-eslint/explicit-function-return-type": "off" - } - } - ] -} diff --git a/apps/desktop/attendance/.gitignore b/apps/desktop/attendance/.gitignore deleted file mode 100644 index e7c3088d..00000000 --- a/apps/desktop/attendance/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -node_modules -dist -out -*.log* diff --git a/apps/desktop/attendance/.prettierignore b/apps/desktop/attendance/.prettierignore deleted file mode 100644 index 9c6b791d..00000000 --- a/apps/desktop/attendance/.prettierignore +++ /dev/null @@ -1,6 +0,0 @@ -out -dist -pnpm-lock.yaml -LICENSE.md -tsconfig.json -tsconfig.*.json diff --git a/apps/desktop/attendance/build/entitlements.mac.plist b/apps/desktop/attendance/build/entitlements.mac.plist deleted file mode 100644 index 38c887b2..00000000 --- a/apps/desktop/attendance/build/entitlements.mac.plist +++ /dev/null @@ -1,12 +0,0 @@ - - - - - com.apple.security.cs.allow-jit - - com.apple.security.cs.allow-unsigned-executable-memory - - com.apple.security.cs.allow-dyld-environment-variables - - - diff --git a/apps/desktop/attendance/build/icon.icns b/apps/desktop/attendance/build/icon.icns deleted file mode 100644 index 28644aa9d97942c50008d03bc0f93505f7824737..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 85649 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

-KY5Dg)0#~?~rEbJH_9`n*G*i+1Ec#;TIAa-b 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 diff --git a/apps/desktop/attendance/build/icon.ico b/apps/desktop/attendance/build/icon.ico deleted file mode 100644 index 7af75f64a37e9b07669af6a17796a202fce4e94a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7044 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%0DMDAY(J#O0(6odcX5(Lo^ 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 diff --git a/apps/desktop/attendance/build/notarize.js b/apps/desktop/attendance/build/notarize.js deleted file mode 100644 index abdace12..00000000 --- a/apps/desktop/attendance/build/notarize.js +++ /dev/null @@ -1,38 +0,0 @@ -const { notarize } = require("@electron/notarize"); - -module.exports = async (context) => { - if (process.platform !== "darwin") return; - - console.log("aftersign hook triggered, start to notarize app."); - - if (!process.env.CI) { - console.log(`skipping notarizing, not in CI.`); - return; - } - - if (!("APPLE_ID" in process.env && "APPLE_ID_PASS" in process.env)) { - console.warn( - "skipping notarizing, APPLE_ID and APPLE_ID_PASS env variables must be set.", - ); - return; - } - - const appId = "com.electron.app"; - - const { appOutDir } = context; - - const appName = context.packager.appInfo.productFilename; - - try { - await notarize({ - appBundleId: appId, - appPath: `${appOutDir}/${appName}.app`, - appleId: process.env.APPLE_ID, - appleIdPassword: process.env.APPLEIDPASS, - }); - } catch (error) { - console.error(error); - } - - console.log(`done notarizing ${appId}.`); -}; diff --git a/apps/desktop/attendance/electron-builder.yml b/apps/desktop/attendance/electron-builder.yml deleted file mode 100644 index 09962d20..00000000 --- a/apps/desktop/attendance/electron-builder.yml +++ /dev/null @@ -1,42 +0,0 @@ -appId: rmecha.my.id.sora-absensi -productName: absensi-desktop -directories: - buildResources: build -files: - - "!**/.vscode/*" - - "!src/*" - - "!electron.vite.config.{js,ts,mjs,cjs}" - - "!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}" - - "!{.env,.env.*,.npmrc,pnpm-lock.yaml}" - - "!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}" -asarUnpack: - - resources/* -afterSign: build/notarize.js -win: - executableName: sora-absensi-desktop -# nsis: -# artifactName: ${name}-${version}-win-setup.${ext} -# shortcutName: ${productName} -# uninstallDisplayName: ${productName} -# createDesktopShortcut: always -mac: - entitlementsInherit: build/entitlements.mac.plist - extendInfo: - - NSCameraUsageDescription: Application requests access to the device's camera. - - NSMicrophoneUsageDescription: Application requests access to the device's microphone. - - NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder. - - NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder. -dmg: - artifactName: ${name}-${version}-mac.${ext} -linux: - target: - - AppImage - - deb - maintainer: rmecha.my.id - category: Utility -appImage: - artifactName: ${name}-${version}-linux.${ext} -npmRebuild: true -publish: - provider: generic - url: https://example.com/auto-updates diff --git a/apps/desktop/attendance/electron.vite.config.ts b/apps/desktop/attendance/electron.vite.config.ts deleted file mode 100644 index 04972dac..00000000 --- a/apps/desktop/attendance/electron.vite.config.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { resolve } from "path"; -import react from "@vitejs/plugin-react"; -import { defineConfig, externalizeDepsPlugin } from "electron-vite"; - -export default defineConfig({ - main: { - plugins: [externalizeDepsPlugin()], - }, - preload: { - plugins: [externalizeDepsPlugin()], - }, - renderer: { - resolve: { - alias: { - "@renderer": resolve("src/renderer/src"), - }, - }, - plugins: [react()], - }, -}); diff --git a/apps/desktop/attendance/package.json b/apps/desktop/attendance/package.json deleted file mode 100644 index 18855a51..00000000 --- a/apps/desktop/attendance/package.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "name": "sora-attendance-desktop", - "version": "2.3.1", - "description": "Aplikasi desktop untuk interface absensi sora", - "main": "./out/main/index.js", - "author": "Ezra Khairan Permana ", - "license": "GPL-3.0", - "homepage": "https://github.com/reacto11mecha/sora#readme", - "scripts": { - "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", - "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", - "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", - "type-check": "yarn typecheck:node && yarn typecheck:web", - "start": "electron-vite preview", - "dev": "electron-vite dev", - "build": "yarn type-check && electron-vite build", - "build-win": "yarn build && electron-builder --win --config", - "build-mac": "electron-vite build && electron-builder --mac --config", - "build-linux": "electron-vite build && electron-builder --linux --config" - }, - "dependencies": { - "@electron-toolkit/preload": "^1.0.3", - "@electron-toolkit/utils": "^1.0.2", - "electron-store": "^8.1.0" - }, - "devDependencies": { - "@chakra-ui/react": "^2.8.1", - "@electron-toolkit/tsconfig": "^1.0.1", - "@electron/notarize": "^1.2.3", - "@emotion/react": "^11.11.1", - "@emotion/styled": "^11.11.0", - "@fontsource/lato": "^4.5.10", - "@fontsource/roboto": "^4.5.8", - "@fontsource/sora": "^4.5.12", - "@sora/api": "^0.1.0", - "@sora/id-generator": "^0.1.0", - "@sora/ui": "^0.1.0", - "@tanstack/react-query": "^4.29.5", - "@trpc/client": "^10.38.1", - "@trpc/react-query": "^10.38.1", - "@trpc/server": "^10.38.1", - "@types/luxon": "^3.3.2", - "@types/node": "^20.2.5", - "@types/react": "^18.2.6", - "@types/react-dom": "^18.2.4", - "@typescript-eslint/eslint-plugin": "^5.59.2", - "@typescript-eslint/parser": "^5.59.2", - "@vitejs/plugin-react": "^3.1.0", - "electron": "25.5.0", - "electron-builder": "24.4.0", - "electron-vite": "^1.0.21", - "eslint": "^8.40.0", - "eslint-config-prettier": "^8.8.0", - "eslint-plugin-prettier": "^4.2.1", - "eslint-plugin-react": "7.32.2", - "framer-motion": "^10.16.0", - "luxon": "^3.4.3", - "prettier": "^2.8.8", - "qr-scanner": "^1.4.2", - "react": "18.2.0", - "react-dom": "18.2.0", - "react-router-dom": "^6.15.0", - "superjson": "1.13.1", - "typescript": "^5.2.2", - "vite": "^4.2.1" - } -} diff --git a/apps/desktop/attendance/resources/icon.png b/apps/desktop/attendance/resources/icon.png deleted file mode 100644 index 407f0aa798171804cabfe57a513c3f768c912ff5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13735 zcmb`ucUV*1wlBPbbVQ0MDpC{?lqMh`9i;ar(m^^%m8!HLs2~{1#JZg`VvEPWJ3nNQ+cVG`asaBZ^S=X zo98Ec2ojZ6S5mn1&}wapD$Zy)p0G(9m6i39I-FzMr0b3l=XS=)=rBX3SB6>hu{wCd zdi+tH$fwi~I@}kuQ}R6Y6vz}3+p?m+WKi>vKPItHQ&Ny0;vkHV+S$gLDUyPZ{4esWH^HW6hcpu5 zE$ZMAB&`HKn9g5i$l^7Cg^5!`U!MwxBcty?v#&@@<`qdf6!>6}fXxM3`EW|m^I;VD z2#W;GX!0FO`D_L-n0AFBOpOyfpS?sPpMArL8scFi4O6q}VS@Ce|8>wmjsK@+EECSJ zi55_!Mtv+fOAZKBI$KW5BMF{F@j0^S`TmDVQW%s?ph-Y{Pt;{T$m8-~%Y#q0e`-dn zKpA}(pfKAruORtMQ3(1T%>}yr+4CQc{MF1LjlXmLBH&mU;~fK*x&N>lB!;w){s3kU zqm@j&oJ$FXaek$Psgc7VFH(_y=q-?fKP+xFw`U7_geRvLB!xixY@_L83S#?zuK7>P z6Pp|)zmzC5zA`{zlo_w+XTu+cyah)GWh>P$Z2@{FzYkG+nSpuMv%Zqt;s+f1v(K3y zaYB$TJrw3?*Ksb4`+pKn&&-qugAAge|7!f>^!|za|38~NHfe1B1}z9Nz$d*HX_?jR zCvf+_2_YVRllU!9c8K0IAEC|wH8wvwgy<(-ZGf~u8}u+J3QDtq zn8ZmSde~3~36BRU#6*?m{2;KEm4liR;$gIDoTs8~ob|jAMM45yj#$18@yPyXCZ3{z zG&-Os-(<=5o93V<)pPQPYZsCu^nY>`f+%0DMDAY(J#O0(6odcX5(Lo^ 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 diff --git a/apps/desktop/attendance/src/main/index.ts b/apps/desktop/attendance/src/main/index.ts deleted file mode 100644 index ab2c276e..00000000 --- a/apps/desktop/attendance/src/main/index.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { join } from "path"; -import { electronApp, is, optimizer } from "@electron-toolkit/utils"; -import { - BrowserWindow, - Menu, - app, - ipcMain, - shell, - type MenuItemConstructorOptions, -} from "electron"; -import Store from "electron-store"; - -import icon from "../../resources/icon.png?asset"; - -const store = new Store<{ - serverURL?: string; -}>(); - -function createWindow(): void { - const gotTheLock = app.requestSingleInstanceLock(); - - if (!gotTheLock) { - app.quit(); - - return; - } - - // Create the browser window. - const mainWindow = new BrowserWindow({ - width: 900, - height: 670, - show: false, - autoHideMenuBar: true, - ...(process.platform === "linux" ? { icon } : {}), - webPreferences: { - preload: join(__dirname, "../preload/index.js"), - sandbox: false, - }, - }); - - const template: MenuItemConstructorOptions[] = [ - { - label: "View", - submenu: [ - { role: "zoomIn", accelerator: "Alt+=" }, - { role: "zoomOut", accelerator: "Alt+-" }, - { role: "resetZoom", accelerator: "Alt+r" }, - { type: "separator" }, - { role: "reload" }, - { role: "forceReload" }, - { role: "toggleDevTools" }, - { type: "separator" }, - { role: "togglefullscreen" }, - ], - }, - { - label: "App", - submenu: [ - { - label: "Settings", - click: () => mainWindow.webContents.send("open-setting"), - }, - ], - }, - ]; - - const menu = Menu.buildFromTemplate(template); - Menu.setApplicationMenu(menu); - - mainWindow.on("ready-to-show", () => { - mainWindow.show(); - }); - - mainWindow.webContents.setWindowOpenHandler((details) => { - shell.openExternal(details.url); - return { action: "deny" }; - }); - - // HMR for renderer base on electron-vite cli. - // Load the remote URL for development or the local html file for production. - if (is.dev && process.env["ELECTRON_RENDERER_URL"]) { - mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]); - } else { - mainWindow.loadFile(join(__dirname, "../renderer/index.html")); - } -} - -// This method will be called when Electron has finished -// initialization and is ready to create browser windows. -// Some APIs can only be used after this event occurs. -app.whenReady().then(() => { - // Set app user model id for windows - electronApp.setAppUserModelId("rmecha.my.id.sora-absensi"); - - // Default open or close DevTools by F12 in development - // and ignore CommandOrControl + R in production. - // see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils - app.on("browser-window-created", (_, window) => { - optimizer.watchWindowShortcuts(window); - }); - - createWindow(); - - app.on("activate", function () { - // On macOS it's common to re-create a window in the app when the - // dock icon is clicked and there are no other windows open. - if (BrowserWindow.getAllWindows().length === 0) createWindow(); - }); -}); - -// Quit when all windows are closed, except on macOS. There, it's common -// for applications and their menu bar to stay active until the user quits -// explicitly with Cmd + Q. -app.on("window-all-closed", () => { - if (process.platform !== "darwin") { - app.quit(); - } -}); - -ipcMain.handle("get-server-url", () => store.get("serverURL")); -ipcMain.handle("set-server-url", (_, url) => store.set("serverURL", url)); - -// In this file you can include the rest of your app"s specific main process -// code. You can also put them in separate files and require them here. diff --git a/apps/desktop/attendance/src/preload/index.d.ts b/apps/desktop/attendance/src/preload/index.d.ts deleted file mode 100644 index ab1657ae..00000000 --- a/apps/desktop/attendance/src/preload/index.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ElectronAPI } from "@electron-toolkit/preload"; - -declare global { - interface Window { - electron: ElectronAPI; - api: unknown; - } -} diff --git a/apps/desktop/attendance/src/preload/index.ts b/apps/desktop/attendance/src/preload/index.ts deleted file mode 100644 index 0733ca1c..00000000 --- a/apps/desktop/attendance/src/preload/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { electronAPI } from "@electron-toolkit/preload"; -import { contextBridge } from "electron"; - -// Custom APIs for renderer -const api = {}; - -// Use `contextBridge` APIs to expose Electron APIs to -// renderer only if context isolation is enabled, otherwise -// just add to the DOM global. -if (process.contextIsolated) { - try { - contextBridge.exposeInMainWorld("electron", electronAPI); - contextBridge.exposeInMainWorld("api", api); - } catch (error) { - console.error(error); - } -} else { - // @ts-ignore (define in dts) - window.electron = electronAPI; - // @ts-ignore (define in dts) - window.api = api; -} diff --git a/apps/desktop/attendance/src/renderer/index.html b/apps/desktop/attendance/src/renderer/index.html deleted file mode 100644 index 511c4cc8..00000000 --- a/apps/desktop/attendance/src/renderer/index.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - - Absensi Desktop - - - - - -

- - - diff --git a/apps/desktop/attendance/src/renderer/src/App.tsx b/apps/desktop/attendance/src/renderer/src/App.tsx deleted file mode 100644 index 7e7af858..00000000 --- a/apps/desktop/attendance/src/renderer/src/App.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { useEffect, useState } from "react"; -import { - ensureHasAppSetting, - useAppSetting, -} from "@renderer/context/AppSetting"; -import { SettingProvider } from "@renderer/context/SettingContext"; -import { trpc } from "@renderer/utils/trpc"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { httpBatchLink } from "@trpc/client"; -import { RouterProvider, createHashRouter } from "react-router-dom"; -import superjson from "superjson"; - -import { Setting } from "@sora/ui/Setting"; - -import Main from "./routes/Main"; - -const router = createHashRouter([ - { - path: "/", - element:
, - }, - { - path: "/setting", - element: , - }, -]); - -const App: React.FC = () => { - const { serverURL } = useAppSetting(); - - const [queryClient] = useState(() => new QueryClient()); - const [trpcClient] = useState(() => - trpc.createClient({ - transformer: superjson, - links: [ - httpBatchLink({ - url: `${serverURL as string}/api/trpc`, - }), - ], - }), - ); - - useEffect(() => { - const openSetting = () => { - location.href = "#/setting"; - }; - - window.electron.ipcRenderer.on("open-setting", openSetting); - - return () => { - window.electron.ipcRenderer.removeListener("open-setting", openSetting); - }; - }, []); - - return ( - - - - - - - - ); -}; - -export default ensureHasAppSetting(App); diff --git a/apps/desktop/attendance/src/renderer/src/UpperProvider.tsx b/apps/desktop/attendance/src/renderer/src/UpperProvider.tsx deleted file mode 100644 index 29f03ea7..00000000 --- a/apps/desktop/attendance/src/renderer/src/UpperProvider.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { ChakraProvider, extendTheme } from "@chakra-ui/react"; -import { AppSettingProvider } from "@renderer/context/AppSetting"; - -import App from "./App"; - -const theme = extendTheme({ - fonts: { - heading: `'Roboto', sans-serif`, - body: `'Lato', sans-serif`, - }, -}); - -const UpperProvider = () => ( - - - - - -); - -export default UpperProvider; diff --git a/apps/desktop/attendance/src/renderer/src/components/PreScan/CantAttend.tsx b/apps/desktop/attendance/src/renderer/src/components/PreScan/CantAttend.tsx deleted file mode 100644 index 97d7b6ee..00000000 --- a/apps/desktop/attendance/src/renderer/src/components/PreScan/CantAttend.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Box, HStack, Heading, Text } from "@chakra-ui/react"; - -const CantAttend: React.FC = () => ( - - - - Tidak Di izinkan - Untuk absen! - - - -); - -export default CantAttend; diff --git a/apps/desktop/attendance/src/renderer/src/components/PreScan/ErrorOccured.tsx b/apps/desktop/attendance/src/renderer/src/components/PreScan/ErrorOccured.tsx deleted file mode 100644 index 346e1ed2..00000000 --- a/apps/desktop/attendance/src/renderer/src/components/PreScan/ErrorOccured.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Box, HStack, Heading, Text } from "@chakra-ui/react"; - -const ErrorOcurred: React.FC = () => { - return ( - - - location.reload()}> - Terjadi Kesalahan Internal - - - - Terjadi sebuah kesalahan pada aplikasi ini. - - - Mohon hubungi panitia untuk memperbaiki masalah ini. - - - - ); -}; - -export default ErrorOcurred; diff --git a/apps/desktop/attendance/src/renderer/src/components/Scanner/NormalScanner.tsx b/apps/desktop/attendance/src/renderer/src/components/Scanner/NormalScanner.tsx deleted file mode 100644 index 05564d38..00000000 --- a/apps/desktop/attendance/src/renderer/src/components/Scanner/NormalScanner.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { useEffect, useRef } from "react"; -import { Box, HStack, Text, useToast } from "@chakra-ui/react"; -import styles from "@renderer/styles/components/Scanner.module.css"; -import { trpc } from "@renderer/utils/trpc"; -import QrScanner from "qr-scanner"; - -import { validateId } from "@sora/id-generator"; - -const NormalScanner = ({ - setInvalidQr, - participantAttend, -}: { - setInvalidQr: (invalid: boolean) => void; - participantAttend: ReturnType< - typeof trpc.participant.participantAttend.useMutation - >; -}) => { - const toast = useToast(); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const videoRef = useRef(null!); - - useEffect(() => { - const qrScanner = new QrScanner( - videoRef.current, - ({ data }) => { - if (data || data !== "") { - qrScanner.stop(); - - const isValidQr = validateId(data); - - if (!isValidQr) return setInvalidQr(true); - - participantAttend.mutate(data); - } - }, - { - highlightCodeOutline: true, - highlightScanRegion: true, - onDecodeError: (error) => { - if (error instanceof Error) - toast({ - description: `Error: ${error.message}`, - status: "error", - duration: 5000, - position: "top-right", - }); - }, - }, - ); - - qrScanner.start(); - - return () => { - qrScanner.destroy(); - }; - }, []); - - return ( - - - - - - - - - Scan Barcode ID Mu! - - - - - - ); -}; - -export default NormalScanner; diff --git a/apps/desktop/attendance/src/renderer/src/components/Scanner/ScanningError.tsx b/apps/desktop/attendance/src/renderer/src/components/Scanner/ScanningError.tsx deleted file mode 100644 index 4af33c5a..00000000 --- a/apps/desktop/attendance/src/renderer/src/components/Scanner/ScanningError.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { Box, HStack, Heading } from "@chakra-ui/react"; - -const ScanningError = ({ message }: { message: string }) => { - return ( - - - location.reload()} - > - Gagal Absen! - - - {message} - - - - ); -}; - -export default ScanningError; diff --git a/apps/desktop/attendance/src/renderer/src/components/Scanner/SuccessScan.tsx b/apps/desktop/attendance/src/renderer/src/components/Scanner/SuccessScan.tsx deleted file mode 100644 index def7c456..00000000 --- a/apps/desktop/attendance/src/renderer/src/components/Scanner/SuccessScan.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { useEffect } from "react"; -import { Box, HStack, Heading } from "@chakra-ui/react"; -import { trpc } from "@renderer/utils/trpc"; - -const SuccessScan = ({ - participantAttend, -}: { - participantAttend: ReturnType< - typeof trpc.participant.participantAttend.useMutation - >; -}) => { - useEffect(() => { - setTimeout(() => participantAttend.reset(), 5_000); - }, []); - - return ( - - - - Berhasil Absen! - - - Silahkan menuju ke komputer pemilihan dan gunakan hak suara anda! - - - - ); -}; - -export default SuccessScan; diff --git a/apps/desktop/attendance/src/renderer/src/components/Scanner/index.tsx b/apps/desktop/attendance/src/renderer/src/components/Scanner/index.tsx deleted file mode 100644 index 6a79677b..00000000 --- a/apps/desktop/attendance/src/renderer/src/components/Scanner/index.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { useCallback, useState } from "react"; -import { trpc } from "@renderer/utils/trpc"; - -import { Loading } from "@sora/ui/Loading"; - -import NormalScanner from "./NormalScanner"; -import ScanningError from "./ScanningError"; -import SuccessScan from "./SuccessScan"; - -const Scanner: React.FC = () => { - const [isQrInvalid, setInvalidQr] = useState(false); - - const participantAttend = trpc.participant.participantAttend.useMutation(); - - const setIsQrValid = useCallback( - (invalid: boolean) => setInvalidQr(invalid), - [], - ); - - if (participantAttend.isLoading) - return ; - - if (participantAttend.isSuccess) - return ; - - if (isQrInvalid || participantAttend.isError) - return ( - - ); - - return ( - - ); -}; - -export default Scanner; diff --git a/apps/desktop/attendance/src/renderer/src/context/AppSetting.tsx b/apps/desktop/attendance/src/renderer/src/context/AppSetting.tsx deleted file mode 100644 index ee166be6..00000000 --- a/apps/desktop/attendance/src/renderer/src/context/AppSetting.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from "react"; -import { useToast } from "@chakra-ui/react"; - -import { Setting } from "@sora/ui/Setting"; - -interface IAppSetting { - serverURL?: string; - setServerUrl: (url: string) => void; -} - -export const AppSettingContext = createContext({} as IAppSetting); - -export const AppSettingProvider = ({ - children, -}: { - children: React.ReactNode; -}) => { - const toast = useToast(); - - const [serverURL, setServerUrlState] = useState(); - - const setServerUrl = useCallback(async (url: string) => { - try { - const serverURL = new URL(url); - - await window.electron.ipcRenderer.invoke( - "set-server-url", - serverURL.origin, - ); - - toast({ - description: "Berhasil memperbarui pengaturan alamat server!", - status: "success", - duration: 4500, - position: "top-right", - }); - - setTimeout(() => { - location.href = "#/"; - }, 3000); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (error: any) { - toast({ - description: `Gagal memperbarui url | ${error.message}`, - status: "error", - duration: 5000, - position: "top-right", - }); - } - }, []); - - useEffect(() => { - const composeAsync = async () => { - const storeValue = await window.electron.ipcRenderer.invoke( - "get-server-url", - ); - - setServerUrlState(storeValue); - }; - - composeAsync(); - }, []); - - const valueProps = useMemo(() => ({ serverURL, setServerUrl }), [serverURL]); - - return ( - - {children} - - ); -}; - -export const useAppSetting = () => useContext(AppSettingContext) as IAppSetting; - -// eslint-disable-next-line react/display-name -export const ensureHasAppSetting = (Element: React.FC) => () => { - const { serverURL } = useAppSetting(); - - if (!serverURL) return ; - - return ; -}; diff --git a/apps/desktop/attendance/src/renderer/src/context/SettingContext.tsx b/apps/desktop/attendance/src/renderer/src/context/SettingContext.tsx deleted file mode 100644 index 61592b46..00000000 --- a/apps/desktop/attendance/src/renderer/src/context/SettingContext.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { createContext, useContext, useMemo, useState } from "react"; -import { useToast } from "@chakra-ui/react"; -import { trpc } from "@renderer/utils/trpc"; -import { DateTime } from "luxon"; - -interface ISettingContext { - canAttend: boolean; - isLoading: boolean; - isError: boolean; -} - -export const SettingContext = createContext( - {} as ISettingContext, -); - -export const SettingProvider = ({ - children, -}: { - children: React.ReactNode; -}) => { - const toast = useToast(); - - const [canAttend, setCanAttend] = useState(false); - - const settingsQuery = trpc.settings.getSettings.useQuery(undefined, { - refetchInterval: 2500, - refetchIntervalInBackground: true, - - onSuccess(result) { - const waktuMulai = result.startTime - ? DateTime.fromJSDate(result.startTime).toLocal().toJSDate().getTime() - : null; - const waktuSelesai = result.endTime - ? DateTime.fromJSDate(result.endTime).toLocal().toJSDate().getTime() - : null; - - const currentTime = new Date().getTime(); - - setCanAttend( - (waktuMulai as number) <= currentTime && - (waktuSelesai as number) >= currentTime && - result.canAttend, - ); - }, - - onError(error) { - toast({ - description: `Error: ${error.message}`, - status: "error", - duration: 5000, - position: "top-right", - }); - }, - }); - - const propsValue = useMemo( - () => ({ - canAttend, - isError: settingsQuery.isError, - isLoading: settingsQuery.isLoading, - }), - [canAttend, settingsQuery.isError, settingsQuery.isLoading], - ); - - return ( - - {children} - - ); -}; - -export const useSetting = () => useContext(SettingContext) as ISettingContext; diff --git a/apps/desktop/attendance/src/renderer/src/env.d.ts b/apps/desktop/attendance/src/renderer/src/env.d.ts deleted file mode 100644 index 11f02fe2..00000000 --- a/apps/desktop/attendance/src/renderer/src/env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/apps/desktop/attendance/src/renderer/src/main.tsx b/apps/desktop/attendance/src/renderer/src/main.tsx deleted file mode 100644 index 1290087c..00000000 --- a/apps/desktop/attendance/src/renderer/src/main.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from "react"; -import ReactDOM from "react-dom/client"; - -import "@fontsource/lato"; -import "@fontsource/sora"; -import "@fontsource/roboto"; -import UpperProvider from "./UpperProvider"; - -ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - - , -); diff --git a/apps/desktop/attendance/src/renderer/src/routes/Main.tsx b/apps/desktop/attendance/src/renderer/src/routes/Main.tsx deleted file mode 100644 index cef7330d..00000000 --- a/apps/desktop/attendance/src/renderer/src/routes/Main.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import CantAttend from "@renderer/components/PreScan/CantAttend"; -import ErrorOcurred from "@renderer/components/PreScan/ErrorOccured"; -import Scanner from "@renderer/components/Scanner"; -import { useSetting } from "@renderer/context/SettingContext"; - -import { Loading } from "@sora/ui/Loading"; - -const Main: React.FC = () => { - const { isLoading, isError, canAttend } = useSetting(); - - if (isError) return ; - - if (isLoading && !isError) - return ; - - if (!isLoading && !canAttend && !isError) return ; - - return ; -}; - -export default Main; diff --git a/apps/desktop/attendance/src/renderer/src/styles/components/Scanner.module.css b/apps/desktop/attendance/src/renderer/src/styles/components/Scanner.module.css deleted file mode 100644 index fa8989e7..00000000 --- a/apps/desktop/attendance/src/renderer/src/styles/components/Scanner.module.css +++ /dev/null @@ -1,4 +0,0 @@ -.video { - height: 100%; - width: auto; -} diff --git a/apps/desktop/attendance/src/renderer/src/utils/trpc.ts b/apps/desktop/attendance/src/renderer/src/utils/trpc.ts deleted file mode 100644 index c032c722..00000000 --- a/apps/desktop/attendance/src/renderer/src/utils/trpc.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createTRPCReact } from "@trpc/react-query"; -import type { inferRouterOutputs } from "@trpc/server"; - -import type { AppRouter } from "@sora/api"; - -export const trpc = createTRPCReact(); - -export type AppRouterOutput = inferRouterOutputs; diff --git a/apps/desktop/attendance/tsconfig.json b/apps/desktop/attendance/tsconfig.json deleted file mode 100644 index 155ebaa6..00000000 --- a/apps/desktop/attendance/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "files": [], - "references": [ - { "path": "./tsconfig.node.json" }, - { "path": "./tsconfig.web.json" } - ] -} diff --git a/apps/desktop/attendance/tsconfig.node.json b/apps/desktop/attendance/tsconfig.node.json deleted file mode 100644 index 2178c605..00000000 --- a/apps/desktop/attendance/tsconfig.node.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", - "include": ["electron.vite.config.*", "src/main/*", "src/preload/*"], - "compilerOptions": { - "composite": true, - "types": ["electron-vite/node"] - } -} diff --git a/apps/desktop/attendance/tsconfig.web.json b/apps/desktop/attendance/tsconfig.web.json deleted file mode 100644 index c0a1a5d6..00000000 --- a/apps/desktop/attendance/tsconfig.web.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "extends": "@electron-toolkit/tsconfig/tsconfig.web.json", - "include": [ - "src/renderer/src/env.d.ts", - "src/renderer/src/**/*", - "src/renderer/src/**/*.tsx", - "src/preload/*.d.ts" - ], - "compilerOptions": { - "strict": true, - "composite": true, - "jsx": "react-jsx", - "allowJs": true, - "noImplicitReturns": false, - "baseUrl": ".", - "paths": { - "@renderer/*": ["src/renderer/src/*"], - "~/*": ["../sora/src/*"] - } - } -} diff --git a/apps/desktop/chooser/.editorconfig b/apps/desktop/chooser/.editorconfig deleted file mode 100644 index 3dce4145..00000000 --- a/apps/desktop/chooser/.editorconfig +++ /dev/null @@ -1,9 +0,0 @@ -root = true - -[*] -charset = utf-8 -indent_style = space -indent_size = 2 -end_of_line = lf -insert_final_newline = true -trim_trailing_whitespace = true \ No newline at end of file diff --git a/apps/desktop/chooser/.eslintignore b/apps/desktop/chooser/.eslintignore deleted file mode 100644 index a6f34fea..00000000 --- a/apps/desktop/chooser/.eslintignore +++ /dev/null @@ -1,4 +0,0 @@ -node_modules -dist -out -.gitignore diff --git a/apps/desktop/chooser/.eslintrc.json b/apps/desktop/chooser/.eslintrc.json deleted file mode 100644 index 4e752de5..00000000 --- a/apps/desktop/chooser/.eslintrc.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "root": true, - "env": { - "browser": true, - "commonjs": true, - "es2021": true, - "node": true - }, - "extends": [ - "eslint:recommended", - "plugin:react/recommended", - "plugin:@typescript-eslint/recommended" - ], - "settings": { - "react": { - "version": "detect" - } - }, - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": "latest", - "sourceType": "module", - "ecmaFeatures": { - "jsx": true - } - }, - "plugins": ["react", "prettier", "@typescript-eslint"], - "rules": { - "react/react-in-jsx-scope": "off", - "react/jsx-uses-react": "off", - "@typescript-eslint/no-var-requires": "off", - "@typescript-eslint/ban-ts-comment": [ - "error", - { "ts-ignore": "allow-with-description" } - ], - "react/jsx-filename-extension": [ - 1, - { "extensions": [".js", ".jsx", ".ts", ".tsx"] } - ] - }, - "overrides": [ - { - "files": ["*.js"], - "rules": { - "@typescript-eslint/explicit-function-return-type": "off" - } - } - ] -} diff --git a/apps/desktop/chooser/.gitignore b/apps/desktop/chooser/.gitignore deleted file mode 100644 index e7c3088d..00000000 --- a/apps/desktop/chooser/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -node_modules -dist -out -*.log* diff --git a/apps/desktop/chooser/.prettierignore b/apps/desktop/chooser/.prettierignore deleted file mode 100644 index 9c6b791d..00000000 --- a/apps/desktop/chooser/.prettierignore +++ /dev/null @@ -1,6 +0,0 @@ -out -dist -pnpm-lock.yaml -LICENSE.md -tsconfig.json -tsconfig.*.json diff --git a/apps/desktop/chooser/build/entitlements.mac.plist b/apps/desktop/chooser/build/entitlements.mac.plist deleted file mode 100644 index 38c887b2..00000000 --- a/apps/desktop/chooser/build/entitlements.mac.plist +++ /dev/null @@ -1,12 +0,0 @@ - - - - - com.apple.security.cs.allow-jit - - com.apple.security.cs.allow-unsigned-executable-memory - - com.apple.security.cs.allow-dyld-environment-variables - - - diff --git a/apps/desktop/chooser/build/icon.icns b/apps/desktop/chooser/build/icon.icns deleted file mode 100644 index 28644aa9d97942c50008d03bc0f93505f7824737..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 85649 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

-KY5Dg)0#~?~rEbJH_9`n*G*i+1Ec#;TIAa-b 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 diff --git a/apps/desktop/chooser/build/icon.ico b/apps/desktop/chooser/build/icon.ico deleted file mode 100644 index 7af75f64a37e9b07669af6a17796a202fce4e94a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7044 zcmeI1^;cAH^zRQ1k{`NTLFth05RsG+q!|UJLy(b12_-E+Kte!bXcdRBQI^NY+h0v9fny)^YdRzWVG^kW}qso zymqT#{2<6(7NKdZhEUHvP|kJQFdj3Ojp`Z>x=Q%<`Qd2L`-7|lQC?Z=`VSwR9e*_yRz#3T z7mC)}rQkw!nEXAe`;#ZM1mkOTCDecbDFgyJT1i^$D@oy^ZQg;w z5>=5%DDEsD5+07Fws8u{ZM#hI6I527tyQ_1__C9N{%9k|ub{m>zltc^;J!rS@&3g1 z;zrVIsjN${(3&0Dx4md^duUV`y;LN|{?n&V7hS&;Y)ZphaOX93t~het-O0(x*P0P{ zvjDU&y;VAqmh1_tgvc6SpL7Vi12l9HCT8m>=P zQtD2l$;-@b%mOfIh29~5ez4$nVE~82i<+CcoZCiNiDw!Dge)v9h%HLv`^cl&qE0&G ziTGo+W)jW0B}6-5))h23@64gNra8Oh)Ktlt@r?AWxRD$Mu1K}P!F#h<5yuFMd8a)Z?`bKZGm?Z~)5 zp-L*MsxmSf%b5l~I!|AxIj2Qn&bKw%+o8vTb8~YA;tZu?LW0t2vdI$@7VVRUOq_*9 zh3^9RNnF3=>8|h8S2Umkc*0K%LXDXK7;I@YSM_2%4bKYO$?sIiE?JIabLyzjjGTG| z3=a=4Ei`_=I7OIk-X^f(d78m4rT*768|GH|lKEG&fR85o3#KO^8HCa0%$5y>1TJdBXu<(BT(DJf^R z?%v)sEG+AvvX)8tPey~_0KUM3*f8gVKXxVn8Mwc{ztt4b{WkA8I<|0ckGl-Ex!~*L zqpz#m{nAYMW%|ao)a_e|FjAx?8+ivYGugo4i3YS3<=g-J=&j616z$!6_xkeG!_+eW zBUZ4dzVBl-l;FnY7GcYoeySlo1c`zdfXc$n&fC09) zxhW(oJNT$EFOPR}#xr>rM_*r9TWbzSFUlytNlXk>`aD|DL~*7^=|aHc?#_B)#Rq!> zO_lFIs_5)apnvs$|B7EgfJj-nv1uL5h!#5hF`EgKE!>yqB=BrE=>a_uEgMh6ac4 zKpQqYHDA4YrQ{rds_oQdtw>}NF@^Xn*rmDgnwl2Wl@pn6TA33V*a}5E6@Mtqh!c2O zNhpcDXKINOTEs>EfTBLil^ZWlxNX8OBlBN>Di1Eq&wZ9txHXbmUj&)>6 z*sHN-<>u{bk-q)N=KQ03hH3g&_5j+mt3Qp;KreQ4gMf>{ zJbn5!y5!AuKhI_g;q~>6ZBv81&!6=?JgB%om|C9V!&F#@?$^>@9QXVHrTeIg#1NSO z>0^L@7mkGROxLXL)O$roA5d!PA7fW-jdLN7R{gikOAhGwccv=J>SlqC-Lf8+eLhNR zYPoP-7C&bFPEc`5N=r!`(?#3|0ds4XfPerEiC&4=4x9EX7GV*Q-Hc`gGNr6cv~n^_ zU<>a`Lr2%O9yk)_0~Ug&UFs%}?D*kfVH&3jcJ|Q(+Ucq*vE@Jo*WXSMdA>IzBgM_i$itH| zGh>Tn3JVYC*45Q*TD)&<9TXhAY%hhkHuqtGc@TrO{bHo~aJjrRFZ4w5RHzj3<}iv7 zb}>5^#Vg;BV6yxPoTRM@iHJ~q`SK+)Y;0@mDH0O^|Mu;K%!WwAe~CXZFt9do{a*Xj z&0Dtyz5I!^937EQDz0O8^FqrHV!333Vd-ZEVprEM1B1gSyT1-A%FA!c%TIQ%2MP!ZVt*|jjfZ6C zd0QBfQ-yT#DxQUO9@wijtCa z6m;B_GXcsGS7I~mnj=y+KGxRb^YgE!@%!}#MfA8RRxp<5eo%Q}5XAmJPEE#)i+=ub zpRr>w8nGfOD0{{gaKrm9<-n$mwaqfBHO#p|wn5ipvpSck+|t&T{Tekj?kbNj@^txq zh-k@yk-mK7ZhHzzu-LYw(RQK2{!B_VgNwvm;{s|SIMj&1(qM(i;cM5wUo!`eI zM-EM*K-{9&fb!)BNg=z05(`}*5Ms^CUuHD+Gq3C3l=s;VjoI?u6b@isdFU?^=5yqBDuycJ16qcAD9EKCy2Q)-Kg@)E$( z`00Hq9yuLKz1W1+t?{C!vEK#;2KVjkh%<-IdRF%$aA*;;ypDKUF15A;kYsJ_sS^;( z$Letm=2m1N{cUY+IZPI*WNzpT!>pnBOHJL!kJnnz>LCZ*zNi+C(^t-I!<>-b@t2QY zzjmC})X+1T?&c~-{@|RC~Tq3$A7Fe0CF=)yRCe%1ICYL0c#8+XN`1sO5_CdP) zMMY_5ODb$DXoH9c0t0?KpWrdBX=Q@&-^YG;$7 zHHc~oa&rH=@?UR%x!id#3Gi>Q&Mq{$RrCvjIT3QL1D%c<#I5yXLP5UAP z!T9^7AoL&cOv?+o#H7T{xVMPy9@RrUCz|4bD^QK*`ot)^HM}3T$&j>ck(gP|$Nv?&H{W3V!_Psm1db86v1eNo(D9 zS!+H7Ry#Dl?CT@f4Z*1gti<{r5%nGwZEa3gPG$0rhwVB4M+B&&LhT(^u$R|P(P*wenEZ^4_{oT? zR>FwH$kz7$L#w}wI1<9#be^t*!{>v6fsY>}?wXn9qtRf$AbRt1MqZUJV);TXySP4z z5cv8ro$eK# zYinzTI);=>7ssp6eP`#t9s(cYtgNh-PS%G?TF~|Jw*G||u?HDm*(IG$erOeDwNHkO zK*!a^*}$*SafuL6Lmk>9k+qIdOiWCzl6OK6QLXxEnru2Qy@}GyEG(MmzckgV!=i_5-DiR#L39WXzoxMwR;&A&dG(g9vQQ3S{ zTH;YwRdM_Dp`YhYq&Kq31a2H0%yIyBUjrMO4DFJ<9NQdigd(YO7)%GO$67gUI+Q(? ziJc+s7)Rmj*Drm@1WhP~z3(2W9Nm(aXI{J*@+)X4d)isxFGY;`Gw;{wTSGsnL5!e? zW+1*l{!S@)=i@LDs;H=#J1^F5soWfNfQDa;Cr~>z z#xj_*uz(O~?|*V1GxdD(&mZ#exr$y`;StnT%QVv2u`Qfxy2hD$E|L-43G9~A`;ea9 z6N~ipO&MvIg{Ay|VPj}GE@5MDpM>wX4Y#<@{8`gECOdnF%z0X#7Nk2}yE*&|q5==j zdm4E5*49=cp9#1?Qf|y;gmY{t77Y4=k`hk3(!X`4dM1daAQknt$i0~W?|FqNy)|TW z$nmlPALRKTmZPw_f807#CsYvpTMmpCP>LFFu4+RG^q(|O81{49t{t%+A?% z|6ScqMMb3)Y87?mVdh6~H*c4}^_{ip7L`El^L@L|8o{2Z*LMX-Bhkx;{%1Q=9nbNu zzJ5Im3k&^`iLS9ZihhOPg&O)PNw;&84(WJRUib9qrPYjq8!Z0O)9|Df;;ZozE@p3O z$<)-`jKi-oa=uMZZ*|GP8xo?3da;@Nf;xxObXekRUV2tzWzL`A;Cgm;ZreY>r+q&p zzyZ*M>g0~&M{!M$2ow|)_r1P%u20a+o++w9$iRRuROSD*>|e09ns$7&T8t_Pbj`=7YG@_Vz0TJEa?ni;DowgP1a;?YYo7A#?R-U17l|WBepB>A>Dk*? z^!KMG7IuJIjzcdh?v;;Q$oK(IJE|R<-+d_3yeMsLn^$?%F0T3w8h`OGb=$|U43hNTYIIilowO{Oo^MFm-l=qnn!??n=XOS z)=n;5;@6YPbH(*zq4607SSde+Zyn8vFb|tVDO!?8W0b=>K}gWt08t(6fN6Gda65^C z(ZPxR8BVZQv<|Lc%*j|8w|IJaNlmYgY6=!ZT}{nv>^rm^g?wMxoBa;GOpmfXZ6Pzm zzIb_)m0w86F=Xzxv?B30u!tvmrZO#XeIN&FLk^wx(5@-18yoK3p#)IY{1=AoaJjj; zPcJ9Ph42jE2&DXpsenwtvO2|sO52#?;$o4DNX}49=#sxu2J+3S)1kB!y7r*@eo;}8 z5<20Tze?%LHjhIwOAWGGTdT)UHeHZS&@{JL22nAw-le4iSIwEqO7Xp!>P5`r@88j3 z=qucu$QKXd90lL5a?8?8@9!xX67+pXM-mhJ$VZU4PhQJVd+L?BdatJ*53xBnEW(2*fyYR~S$(NTecjKSx2O5CRH@$X@`py#tsT2s}+ z8K-L;4KlTJ%agu}YDGrIOxN>sa&htQL%Fv&x%y*?0AZ2qLb&Z9WS|LK&hPPASKC7Z z*y+F9U4NqoX2afjXnW*O5EiaqUmnhqGivhijL>cUQgzcAOvTU<&+zqA4-~si~pBJD;aKwY59%_w@E|XH@u=IiEIByBDa< zagSTY-e^J1i@_jUQP25z*M^>G-YiNhHCx1tw~Mx#=mf zq_{c)%R0-}z9Z;q_YQ#Hl2M0WMdjM%B^XRRFH z^_2LlP^B9<)f3rI(!fAaEMS5D3K`?Y@nkkPH@kkHHHPnBLjJ9Yv$Akd$kw`>RQf9C zjKt4}WVjEVxpab$mt{{C%JL6(;7mE6K6T7KYe!>xDqOn3rT;i3OQ{C=)8>Btt@tntMFKcZ$$A~O*A9;}>+9wD0)*O#@6M$s8E!hgQGKU@LqWs0cotv^>(eKv4v2axw_Y@{1#JZg`VvEPWJ3nNQ+cVG`asaBZ^S=X zo98Ec2ojZ6S5mn1&}wapD$Zy)p0G(9m6i39I-FzMr0b3l=XS=)=rBX3SB6>hu{wCd zdi+tH$fwi~I@}kuQ}R6Y6vz}3+p?m+WKi>vKPItHQ&Ny0;vkHV+S$gLDUyPZ{4esWH^HW6hcpu5 zE$ZMAB&`HKn9g5i$l^7Cg^5!`U!MwxBcty?v#&@@<`qdf6!>6}fXxM3`EW|m^I;VD z2#W;GX!0FO`D_L-n0AFBOpOyfpS?sPpMArL8scFi4O6q}VS@Ce|8>wmjsK@+EECSJ zi55_!Mtv+fOAZKBI$KW5BMF{F@j0^S`TmDVQW%s?ph-Y{Pt;{T$m8-~%Y#q0e`-dn zKpA}(pfKAruORtMQ3(1T%>}yr+4CQc{MF1LjlXmLBH&mU;~fK*x&N>lB!;w){s3kU zqm@j&oJ$FXaek$Psgc7VFH(_y=q-?fKP+xFw`U7_geRvLB!xixY@_L83S#?zuK7>P z6Pp|)zmzC5zA`{zlo_w+XTu+cyah)GWh>P$Z2@{FzYkG+nSpuMv%Zqt;s+f1v(K3y zaYB$TJrw3?*Ksb4`+pKn&&-qugAAge|7!f>^!|za|38~NHfe1B1}z9Nz$d*HX_?jR zCvf+_2_YVRllU!9c8K0IAEC|wH8wvwgy<(-ZGf~u8}u+J3QDtq zn8ZmSde~3~36BRU#6*?m{2;KEm4liR;$gIDoTs8~ob|jAMM45yj#$18@yPyXCZ3{z zG&-Os-(<=5o93V<)pPQPYZsCu^nY>`f+%0DMDAY(J#O0(6odcX5(Lo^ zo0%QBR$Vs|^3qCQGY)Lp;2H6Fm7~J@+RfbH}hNPoc2=4039xmctWj)PVHA z<|1Q{qHu30dbRm?&qIllQ{5*?J0{qJ4$@&_C=zA&+#&e4c}dqMQR+ElGyjzx|4(&wO#H~~)h;lzx6=X?G# z2lQEqN5TiyNUj8n%kE~?Z%TAR9g4r^Yb6SAZMLy%dUzBEA1o#;U58RwVH_#~jr+Eb zmQ$$=5LU;tFJ_FMN~kvCRrHWVNN(%k&8G+A&-3pVB^}Kn@El-Ky4&wMv3nDZ zA<5E5?;;)A-*Wvumszz3f9LxpHPpa*$t2Q{@ItVWXKxDbd4nbPrECbUgQge3QF(t@ z*myEmm%v%V&Omy#HTj28W30Z#Cs-6!$N)blWq`{J+K5e5p)SH~Jd>T1Go{a%WhSAwFRgP(rVicj8CSvN0te8p#3brS_;bY3Yk zt9-lND>q~DU}8BPE-;v@>vYU6KR^iy_173Wxr|rjpRdvO#Ep%adSj9LK3os9lWxYJ zb>UAbezRW{K|$AnMGbaQLX4_V6fAXw?Uq-DxOnAMU4Z6Y|Eh~}p_3NsF9l+GWW>U}HPrd>v@jzZTdr-c}KK+&9A}1A(!Q`d)y`HBr) z8Ym>QWH3d-2axsl8>4({Jiqzfz@3#d(o?wEL0!4{#ZI5wMTBa%F@q#=PY;##WDzH^ zK>~D}6H>DH{o_@R0}VWS+<$GH>jg8vPA$%QXJy#@^POT_6%Q%zg(xk19DC`~_M`}m zW4_jR{c?h}Rp*NxctfK5c!j#y88=~LM&r!e$|%coQ1F-F18>the|{KLy0Fa+On}XD zYCu|r%iz^t6YD2JR(d;nG-`KBRwnb`-E{72dO0cXK0cIT$_PDChN4O}1sm?Hj1(+) zV+oH>P}t+^A34UQTvCkeP-eEY2gK}-37J+r)c=%03e9jpN^v>|;vPdL7l!th^Y7A? zw|94ner$~wgulUJMrU45CL|k^WA;^FDXERKJ;EP7 z%B;OY(iA`(mD3S_MP_P!gVo(d%5F*PV70ur>&)Jr;yb3~mNGwU(-rUDeeX6=)%_A` zIt80e*P}~(sgo+HUS#I)gkff4`WeX{io_ba*X^!ltGv1}BE@6{4=D`ZX&-ML@iar()f9&D?kuR=>p&rJ$lDRSyf8*v?%TuV=sm_e<; zWl}!5PgFTFy*^7QGWk-!vEyB>T&C%J_a%EBI}>{kiM)ZY)C5qy=a``JLRWN!|AfDL zBRsdEk%jf()H&JUuk{B%Zi!HH^SVBsd^DQcd`bNx{KDbQ0RFPN3Db)Or7y5Hfd)(Z8QnsQ2#TrUfIKxJ0(_A7V2p_1j1GVBCZX;t?%<) zMpALBqqC$Vih;8$egQ>G*qkBgum!Dtxpx)n&u)`m=Ii_-5dB@Qet+P_R(bkyT&ke* zOD^b&*1Jb4Dk`N_?!TKJpAdFjB_zE`1{abu1cw#+=;b>lCnzZRi!2OTJNJww-2ICh zZR(j6DJPviT&>ch4QDM#zD1yJRoG>;xM2rJ7;w;ZXnW1-xR4uqj5C#E@R+vXI5;@K z;$up;d!%$d>gQAn7H=Ghtevd2`Tgwe4BM)L1PR9MN+IYz@aX|(fnZ&)9Kj-2D{&sH ztzE5d<7z%AA{>Nvi}O@USB*;5M~cl|igu|>bhpE3rw$RoZro$ZC;;|`KVkUm zYLNI-dryYJTpj*{>&_|fd!G1VrY(Eq2*EyNRW~7f{-1AwtxV=sep0_10HiiMVD6Q6 zHEjH6cQ>uQFHj<}D${cvnhHRU?)1im_2m8Ncx+%XuhQdS!a^)h5s|tKalFdT#N%zQ`jFuCm ztZM_%9>bSS!6no#D=+Wn$&wb#8enBxo2aawW{80AZ*>YTeowIS4h-^QVt~9+2kw0SaM6XON1550DdpI+`f9L2u2w4JaUMtSi%&Nfz~-V?2u!%yh@IvD z(rD&L*xXC}hBbkIZw8JqDnC6n5j3@7BOI`90WMpurtSQb=TF5WJHcgh*-p3lRveEK z$(5SACbW)CiIf8#T+V}!Do^1~)>}jOGl9)2bHZ3XT1rH8WUIe>IKwPL0_hakK8+Rz ziU^&@x^^UPyFfWs?i5oqe>TLOEf8t*Xtlqs&=DFbu@+Q(cIth2IEfFL{`W!neTFOz za!u$K*Kzn@o>}U8#c^5k(R5JH^ilkX>PfbnKY;}@PcJJg;|Y(ZYOvXSSIbU}lyuZt zQm~kr8Qcd$nKrAuU5i}9mbLL}@sC~=vX_0DkM8j@K};0Held9W8QCcua%OdvZ>-Fr z{Cth7lj<|tI-L${lWA~z1q9=g9=gw&u#8*uke5cKt2KkKRCy|%T`n|OZ*u9n3@%`( z=wla8J&>TE#gK~CGnb5UkXoL8z7 zCGZ@|iiE;2{dwbQGJ)++nXR0QujqQ>e3rXtUsw9Yi-n&LouWkhmzr@4!9#NgMYx`4 z6tnSIkwU>SvIl7WJm7CMS=x0)m&x2amAkzz|b6@1p&i+^=b zJG8F6Zv?LFXob)AR*LKxFXzVy3#-9mo!d?txjLzrfgTevhB6+~LmRqSM={%dFZ4&a zkGHq|Ok;!N0^1a8e~u3`dVALInSDzu)#_7ny;VwRpF1$gGC{zyo9ALGezcrmIXzhX z6yBXIUVZ;fAy5<73XJQ5)EL8%m!VmVFMrT%)8mmKLIBZEOyGO}yDbK|w=RRV+I8fOk@z>y~v0Jeh%fHRDRxZDH3% zhtBu3)L4T9$!kULz7p_z;}??=!cybvVF3!z$aq78nQF2esHw?dx(kYw0qnR z@P9g@<~WOj6!vfT9%A~06t}jvy3WL1A(4OS9NCy)6=*6MuyT>~@tu%<^eY2ImmBXm zB2%{_X~R7Ta_$3O&UVD_C#o@x*1PN4Zsv2}nXUO^q_;jzhEA+0T(Z6H8HJM^JF6b5 zNU)H!dmDWs42#nEW*n1d-0_-Ks>_HeXQariQwpOM%Uk@l#iDtP&#K;poVGE^u7ooj zqUT&2uS{4iT%ARYaR2_%a4lfQC!l45`fv{|*ektVI8|1oeisT{3hKb$zEBaO5=2whT+qlav>Y`;ef zAJxt|F3?%(e30GJcKe1SD5bI@7Xrro(*Ivzlh^cKDg(H zzdzOxBrbZL;Wmg7#oWh9$pRZq8-r`yj=c)br&>mk@8mpq_hb5u12Mb-3a$5bxa~l; zx?#RmLtw`XEz2BCr|7{!;j!vT7za7_n%zc5vsLI=J#L(rO85MVHbU8qCf_`-xMrTB zsL-nN{ux)ocO|C;VAt807B34O?ykKnTy;Cu5PdEhvdb_ncih7L8s#e5SXw&WkXd(wzq#_rNK zEu=M@xh#y+SB(qq@&otH%}XO)*Oz?ums8t%XhE;pg0u@OiD;3w&i(JG# z)6Vk2_Ei#Ov5whKv-FSqQCs2N$&2rFWrg4?4+nbffEQeIz^#z5;n_Qt&Y+ z8c^s#Lga+(P2+jZGm%b{!CdV{al|8~mC*u0 z)fhJI=9}Z{5_^-|*+A3{@(sAqwIuv~Afpphswb^+=|YP%z2Gq0Hn!k1I4 zQ$1EQMIbW^XP@oSQKymBMDP95O?PaJ#?bq;^xmWo%?bJ7 z*BPl|?q(ZIKe3+m*tNkn986&bGk%!9B-gJ3ruwGxDqPflVsNXX+&D`)@If{@W7NXu z)%mA+?w;bF0&427l%BreRhtlV@8(HrtQkf#6IeVO-@HwvV`8VRx2zi%mjv0j)Zh(R zo}mh>D8$y^u?Up&duswtyqQ*j&$5?Q*7o7t>FGPbPXzX@%(q>*AkY1HD`=}%w?i;= zb33jJa&{h+K_*yuyY#aMSnLnst6Ss>B~fF`0aGD)S}C`LMh0j3%`4(kQWmK^Lk3H?lT}0c zb)`2+zr%&HFhMd}XrikDj`np<|3}qM!V$*xTH?X&ejBPLP8{~bwCvPMZf>_lu5S7g zc06tU^*L$hQlriEfvX_;5_q}OZ5_n-lI>XD;BdOyZHjR0CF~Im*pI?6p#z4&OyY(G~ff z2O)ck;g9u0g^2(`V83@UF##JpWMEd&`$Mt#V~P*@4|%j_QcvjdWB2`Qr9Lh^gR>_hl5cvrSz3IKB=A``rmPS?a6k`lv;0JM zcR%wl>+I|lMH4(in*)%23in3}U1W|+@@8C0o_rl(c|4+UWsu*%6Gse?^+=!X_aR)} zAI3);s`^c!Yb#xWs#AqF!p?fr*6o^!Q0tERZ5uBq`+5dMyu56A6*Zod?6;k+7)jUu z&8~sm@Vi-T{pz9YNuT}q+1o|RAPlS zoj{?^k95mmbJNoiX8x_C0!{}HlrtXvv+hPAIx}=V~CgBDT&nQ(#Q)<55k+~ zB}7^n<$0vuE67_rv_f!V=>-#Lo>(eE8%@K;mS2=RRZh%obbxpQ6q<*Yo{-U8vqOJ!*$U;q|O&LV1jwu#~;fo<*W7i)a4Jjsoo*a zN#{1iS$=c!&L?6w_fd2AK!$>uB>H0N_^%{gs&AdPwh(J`5nXNpFy)Xj`iry3l9bA z))k`~nDYvQ%vc(8Z#NuO7c{h)7DMqCSYa-t}6Nvc_+ItdkxX zNCMtH@7=;!5auQ_%!fm>rJx9*LChW4*}Eghr-9Sb^;7#>G3!;Mg1e$0y+HEq8pyIJ zh{W9*FwLT1d#ERXklu8NZ3x=yczJ$MVn;By)^F`9NcirBd9D5udW1P{?}EP5!j4> z2l~n!I#bq9qOMyKMt2ca*mmpQ&?wi9o%3ME+T5uxcHC&0H5`|m&Ad{cR1l~s$eNIp zW)2cwFbhVzUrL!i>48|=&7Ky-N%i)oupe%p3eF)b8DHxk8LxD8z@#I`#0F*qE&Di5t=o5vuCE&D;8| zdgdJ|M1eB%SQ|Gcov3j`G9M;;HqK$21tiyG?2&qCOU6JA+XtA(Y+`O7CVdCHpXz#7 zM)ZX2pv5V7468k+IBs06J7K#LcddC&?^eSn6;A$>Y`5-Z0IT9B$a|O1>CpOR@S%)4 z>G|Kq9gQEM9|5aSQj=k=MyK>aYfU% zJS%qSO_mx(ty$7uxEZIzDrLGSKZ+t46+{PIulHFN7$HluLHS||N*X#UJhhC942*<@ zg$uuZtfH_kv!`kx*cIME3Fmy8ebA-LhJXGii7q=^yXHCM{xRIn-O}%HO)hTccsIZx zN8^kSSBjk6j?*J#CPnwBC}iHyk>3hq?=PVe3Q?%)`wJUnRUeCIt@1=U(W3;CU8b-J@IkOey}FrHZVK_Z6O-cw#cihzVS4 zJFH@Z{icD-NMT|PW6!EY`C?ko`l*q9i)>}R3vWGG{E~-HhNXp$mK)QVR^E@VF~D9B zbLRQohT!5itGHnt-TS6R5Y6IQuaK*q{-d@eQ_~$3b>6(^H&5bKyL;FFJ$8NS#n=zQ|ph5xv$*MYn{(3mjt;wUG<1Od(`P{vEcr3 zdJjCIugcy0Pg?VmspPwx>)j9P^E4n>(e`1J55g}>KkT!3+z9t*@Vm1yrg)1z^sA9$ zPm%(}10#hZoiT*zhC>qy$nu4Gty^OGFhO#0CRcmn9&hR^e)H;|pzIaB^P(WIazz0i z`PSrc=Bvv`KJ%)$G|Y_m+*9WFNPjKng~dg(2@Ual*Z4l-B5p>JqIqY}$?vqc!SqgwIBHV$B1G;rP z?^$~AR;KAN|1^fI?@+7%En zh;r^Bshp2-d*UBAu;(U=*galN(dt(}pAPKg$v2SLoTiY}K0Ko7l{#*u{0J&nTq7^r z$s~Q=BLe;OETRE|#jA;o+2Y))LY3v==rjSNlLX3qEBznyZhDSPf^yRe;Yjr0%hCM( zp;G<66S9fBZOP)E@1LLMPGX4LeS;j&;T6Q0ybP=%v+x?TAmg? zbi68Av{q&_YUOHKnqzI@N`v@1FXo=#sc1&X5K(?UkVinsZZp^7LsWrP&M_(&VIr9B zdrd?t#HzGkf?8AlsxJ-VY1oIsyjr3=WB69A z%5z45hMC{H_MFrH27^IS(o{bJhqHHZQgxp3wiV}S(64l#&>64PJfbwQTzR-SY-~ST zoVr9ExBk^-s;wwGVJsN)ha3Uik`z!0N z4!B@p_Tr|xt49FVbK_2Od_3-%7()`PI|$E9Pc->=-1Uqer;+nWy6JK`%&7Upn)Q-{pNoD&i@DK2$qEvi&FeN`*==xF$E=M$VpD&Nrz z0=uP|a*Y8-8g{M@Bo?_ud=&I19qk6P0~Q+(G4DX|n8@ot z>!6|%*w?_PgGIsnTfS3`K1VVi)nOWW>7;LskL^97!#0Fs8umIq#z`V|h{$^U^ zo#Tt1j<=$JZ9~QJgPisPU!_2agUXz<$m^7pi`Bq{nseW}Jm#Og5QNb_l|*QLJ|ZiP zomEtyf_yt?DqIXBOrcg|P>0X)oC)>DA1s5w<`v7HBYoK1SrU0IRK@yF2F{`v!-@1A4es$gaQNLXEn%gT3HYn4yd}(CD%E-#f!%Ky7Qju2v zBr5W7ItcS_2EXhXF}*H^$BiO>A}Lg)%uz*tjHaez3irx{Kt9BKCS;F%0{VS>LJ@Ja z#XIoX!COJ!DFU0Xn-HlnACg%vhTC39Pmi-cUP*9MJgI<|!DHLx+KZ}Rmj$Zz<>Au_ z^UGWNzY!wF^b|Ppw4dAwA6OLw2ib2r>uprnD?EnLiU>;7PF z(J+|hROU6qX>rb0wZ(Bq^;adV(5fX9Rwt@wES}3Y!0fY~)bd`lcE|6VR(NF& zN*PAK9r5r*k55VNvq^X>fvj4)OUNkz_2?Mm_NX8{T<)5Y#d!GdKhU%(5$E zL2b{hgz!Et&bX+I=cL$QkyG%t=r>t^x8;vbPD*X-(`EkiYcC{BJ zE~E=8@Y6RO_Z2-0F*6e!`K%{)95J7vqMFS1R z%iYOxy{L#=_lH08%M`ELOu@8AGk}S_m@KLnVl9pB`_{bM@O5C%VJV!fkPvy(cHw8n z{_wNL86O{G(mLx|F+f~)wQe=u%at>^tE(%c|3!iDz+jGz;-Td8qod%luh(SFGb)xJ zuLqVMH)sy1vWHwLFfPg3;3K82KS6PlGFsjp$)`2l5uY!|cJa+`N64$xF$ZLRg>73Y z(RpV4M}xlXd0AEVNGu#+kHV)ItFZDZUx(D44=2$jh)d2{r;W|ra>e1aFek>|fgf3; zAfeMTJMpu2-_Xe#RD#qu(}Z{`?5(S|5O6OO&;%Jt$GJZV24Tww?)9NR8?a%FYhO_7;jVV{{X}M^@k- znnY7cSKm>)u3ThVe)Sg#<=%s{@>+#TF-`$DeTPUh^-Jk>=|F4Np? zS-mpYfS>unX@q)5_@rO0j=oz&H%;qt{SZ?o9G!9$IK`^7vy*#zWH$ z{H;x+`snJ8G($0DQ)%a(~UzXE`{ z!xr*?m}3Kw$5uUzY(o)U!vH@VJz7F{iYfyB2mcr-_S5J@NmUx20E%GtrgMV??(|YT zhLbo%D=@IO!hPb0Bm`>uChz&I7*kp)2>=1f;TX8H%EfGL(F_)Js$0O#B zr0f8%%5fE&j{rfVcRLq2&bh_q<<5Do7u}SymjR$x3t5G@`ih0Z3Hp^JYZq8b!@;*g zG9Lo~n?m|^{_goR8IR9Dtq$4~NE8L7sHcjS@<+re$%$C7jD1Y>xG_j?I|y3v6l@zO zGXw)LY1p|w7Xq8{ZmBHJSobLTrpw!l65g#Ak4q?_>3c1wqRJ(Whp2Q2#4zEQ#Z^H0T{dfdhuoWx-eapw^QCAH%Kz-NvZFewc~!I`r; z5JW2GKO2{@0i4e`KxevvvX)NDEt_JFDuWyOnt%?irP5?$CVhqgXMd|No-E%DB z9&rGl#RF0(diB0yh$|kgMAB|plJFQ8TWs`}(CmE+ac~_bFsH*WS*M<4=9neQ2gH^| zeC6Q}p!r`Ruzx+ZjnTdcg)J~ZwlRiy2x|Wa_MbQq9M3<2i~nv$!=ZaD(igvz>q5%c z@USpX^;^+yG*6l+xNV3??a5|E8{jYh^V_*(7#><(!297oRA;V z!$BG6051AdCKe_y^k);9NOCAw0S561_tQdJ5hReFy$Q+xhI;Pky3{42~$FPQ4@~`|8*(b*QREW4WRGCM;#jZb3nfS0(swx z%=uDIl#G;b1HZ=p?TpmzUdiCWI$_ygez3vkNFew&I3=^MP}3wGc%kM85#>#kCHaeA zm}oSKGRyhi*5)~Q`tN9TdYgQ+l{|Zh$n7vnO`|Vz$>bZB|3$@L+q@kWWJv~_y~*wlAeIbt^w%z6E28Dq4l29d5rEA@JhjzsHZpnZvX4Zzt1;}+WQZAcVbWc z)4fsV4*}m;2z0%ZD#ymR=}&-pAa!LOrQ+N7!v7BryktB8 diff --git a/apps/desktop/chooser/build/notarize.js b/apps/desktop/chooser/build/notarize.js deleted file mode 100644 index abdace12..00000000 --- a/apps/desktop/chooser/build/notarize.js +++ /dev/null @@ -1,38 +0,0 @@ -const { notarize } = require("@electron/notarize"); - -module.exports = async (context) => { - if (process.platform !== "darwin") return; - - console.log("aftersign hook triggered, start to notarize app."); - - if (!process.env.CI) { - console.log(`skipping notarizing, not in CI.`); - return; - } - - if (!("APPLE_ID" in process.env && "APPLE_ID_PASS" in process.env)) { - console.warn( - "skipping notarizing, APPLE_ID and APPLE_ID_PASS env variables must be set.", - ); - return; - } - - const appId = "com.electron.app"; - - const { appOutDir } = context; - - const appName = context.packager.appInfo.productFilename; - - try { - await notarize({ - appBundleId: appId, - appPath: `${appOutDir}/${appName}.app`, - appleId: process.env.APPLE_ID, - appleIdPassword: process.env.APPLEIDPASS, - }); - } catch (error) { - console.error(error); - } - - console.log(`done notarizing ${appId}.`); -}; diff --git a/apps/desktop/chooser/electron-builder.yml b/apps/desktop/chooser/electron-builder.yml deleted file mode 100644 index 8459941a..00000000 --- a/apps/desktop/chooser/electron-builder.yml +++ /dev/null @@ -1,42 +0,0 @@ -appId: rmecha.my.id.sora-pemilih -productName: sora-desktop -directories: - buildResources: build -files: - - "!**/.vscode/*" - - "!src/*" - - "!electron.vite.config.{js,ts,mjs,cjs}" - - "!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}" - - "!{.env,.env.*,.npmrc,pnpm-lock.yaml}" - - "!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}" -asarUnpack: - - resources/** -afterSign: build/notarize.js -win: - executableName: sora-chooser-desktop -# nsis: -# artifactName: ${name}-${version}-win-setup.${ext} -# shortcutName: ${productName} -# uninstallDisplayName: ${productName} -# createDesktopShortcut: always -mac: - entitlementsInherit: build/entitlements.mac.plist - extendInfo: - - NSCameraUsageDescription: Application requests access to the device's camera. - - NSMicrophoneUsageDescription: Application requests access to the device's microphone. - - NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder. - - NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder. -dmg: - artifactName: ${name}-${version}-mac.${ext} -linux: - target: - - AppImage - - deb - maintainer: rmecha.my.id - category: Utility -appImage: - artifactName: ${name}-${version}-linux.${ext} -npmRebuild: true -publish: - provider: generic - url: https://example.com/auto-updates diff --git a/apps/desktop/chooser/electron.vite.config.ts b/apps/desktop/chooser/electron.vite.config.ts deleted file mode 100644 index 04972dac..00000000 --- a/apps/desktop/chooser/electron.vite.config.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { resolve } from "path"; -import react from "@vitejs/plugin-react"; -import { defineConfig, externalizeDepsPlugin } from "electron-vite"; - -export default defineConfig({ - main: { - plugins: [externalizeDepsPlugin()], - }, - preload: { - plugins: [externalizeDepsPlugin()], - }, - renderer: { - resolve: { - alias: { - "@renderer": resolve("src/renderer/src"), - }, - }, - plugins: [react()], - }, -}); diff --git a/apps/desktop/chooser/package.json b/apps/desktop/chooser/package.json deleted file mode 100644 index b7c4e0d9..00000000 --- a/apps/desktop/chooser/package.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "name": "sora-chooser-desktop", - "version": "2.3.1", - "license": "GPL-3.0", - "description": "Aplikasi desktop untuk memilih kandidat", - "main": "./out/main/index.js", - "author": "Ezra Khairan Permana ", - "homepage": "https://github.com/reacto11mecha/sora#readme", - "scripts": { - "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", - "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", - "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", - "type-check": "yarn typecheck:node && yarn typecheck:web", - "start": "electron-vite preview", - "dev": "electron-vite dev", - "build": "yarn type-check && electron-vite build", - "build-win": "yarn build && electron-builder --win --config", - "build-mac": "electron-vite build && electron-builder --mac --config", - "build-linux": "electron-vite build && electron-builder --linux --config" - }, - "dependencies": { - "@electron-toolkit/preload": "^1.0.3", - "@electron-toolkit/utils": "^1.0.2", - "@nut-tree/nut-js": "^3.1.1", - "electron-store": "^8.1.0", - "serialport": "10.5.0", - "usb": "^2.9.0" - }, - "devDependencies": { - "@chakra-ui/react": "^2.8.1", - "@electron-toolkit/tsconfig": "^1.0.1", - "@electron/notarize": "^1.2.3", - "@emotion/react": "^11.11.1", - "@emotion/styled": "^11.11.0", - "@fontsource/lato": "^4.5.10", - "@fontsource/roboto": "^4.5.8", - "@fontsource/sora": "^4.5.12", - "@sora/api": "^0.1.0", - "@sora/id-generator": "^0.1.0", - "@sora/ui": "^0.1.0", - "@tanstack/react-query": "^4.29.5", - "@trpc/client": "^10.38.1", - "@trpc/react-query": "^10.38.1", - "@trpc/server": "^10.38.1", - "@types/luxon": "^3.3.2", - "@types/node": "^20.2.5", - "@types/react": "^18.2.6", - "@types/react-dom": "^18.2.4", - "@typescript-eslint/eslint-plugin": "^5.59.2", - "@typescript-eslint/parser": "^5.59.2", - "@vitejs/plugin-react": "^3.1.0", - "electron": "25.5.0", - "electron-builder": "24.4.0", - "electron-vite": "^1.0.21", - "eslint": "^8.40.0", - "eslint-config-prettier": "^8.8.0", - "eslint-plugin-prettier": "^4.2.1", - "eslint-plugin-react": "7.32.2", - "framer-motion": "^10.16.0", - "luxon": "^3.4.3", - "qr-scanner": "^1.4.2", - "react": "18.2.0", - "react-dom": "18.2.0", - "react-router-dom": "^6.15.0", - "superjson": "1.13.1", - "typescript": "^5.2.2", - "vite": "^4.2.1" - } -} diff --git a/apps/desktop/chooser/resources/icon.png b/apps/desktop/chooser/resources/icon.png deleted file mode 100644 index 407f0aa798171804cabfe57a513c3f768c912ff5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13735 zcmb`ucUV*1wlBPbbVQ0MDpC{?lqMh`9i;ar(m^^%m8!HLs2~{1#JZg`VvEPWJ3nNQ+cVG`asaBZ^S=X zo98Ec2ojZ6S5mn1&}wapD$Zy)p0G(9m6i39I-FzMr0b3l=XS=)=rBX3SB6>hu{wCd zdi+tH$fwi~I@}kuQ}R6Y6vz}3+p?m+WKi>vKPItHQ&Ny0;vkHV+S$gLDUyPZ{4esWH^HW6hcpu5 zE$ZMAB&`HKn9g5i$l^7Cg^5!`U!MwxBcty?v#&@@<`qdf6!>6}fXxM3`EW|m^I;VD z2#W;GX!0FO`D_L-n0AFBOpOyfpS?sPpMArL8scFi4O6q}VS@Ce|8>wmjsK@+EECSJ zi55_!Mtv+fOAZKBI$KW5BMF{F@j0^S`TmDVQW%s?ph-Y{Pt;{T$m8-~%Y#q0e`-dn zKpA}(pfKAruORtMQ3(1T%>}yr+4CQc{MF1LjlXmLBH&mU;~fK*x&N>lB!;w){s3kU zqm@j&oJ$FXaek$Psgc7VFH(_y=q-?fKP+xFw`U7_geRvLB!xixY@_L83S#?zuK7>P z6Pp|)zmzC5zA`{zlo_w+XTu+cyah)GWh>P$Z2@{FzYkG+nSpuMv%Zqt;s+f1v(K3y zaYB$TJrw3?*Ksb4`+pKn&&-qugAAge|7!f>^!|za|38~NHfe1B1}z9Nz$d*HX_?jR zCvf+_2_YVRllU!9c8K0IAEC|wH8wvwgy<(-ZGf~u8}u+J3QDtq zn8ZmSde~3~36BRU#6*?m{2;KEm4liR;$gIDoTs8~ob|jAMM45yj#$18@yPyXCZ3{z zG&-Os-(<=5o93V<)pPQPYZsCu^nY>`f+%0DMDAY(J#O0(6odcX5(Lo^ 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 diff --git a/apps/desktop/chooser/src/main/index.ts b/apps/desktop/chooser/src/main/index.ts deleted file mode 100644 index beefee53..00000000 --- a/apps/desktop/chooser/src/main/index.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { join } from "path"; -import { electronApp, is, optimizer } from "@electron-toolkit/utils"; -import { - BrowserWindow, - Menu, - app, - ipcMain, - shell, - type MenuItemConstructorOptions, -} from "electron"; -import Store from "electron-store"; -import { SerialPort } from "serialport"; -import { usb } from "usb"; - -import icon from "../../resources/icon.png?asset"; -import { handleConnect } from "./keyboard"; - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -let port: SerialPort | undefined; - -const store = new Store<{ - serverURL?: string; -}>(); - -const arduinoConnectionHandler = ( - boards: Awaited>, - mainWindow: BrowserWindow, -) => { - const filteredBoards = boards.filter( - (board) => - board.pnpId || board.manufacturer || board.vendorId || board.productId, - ); - - if (filteredBoards.length > 0 && filteredBoards[0]) - handleConnect(filteredBoards[0], port, mainWindow); -}; - -function createWindow(): void { - const gotTheLock = app.requestSingleInstanceLock(); - - if (!gotTheLock) { - app.quit(); - - return; - } - - // Create the browser window. - const mainWindow = new BrowserWindow({ - width: 900, - height: 670, - show: false, - autoHideMenuBar: true, - ...(process.platform === "linux" ? { icon } : {}), - webPreferences: { - preload: join(__dirname, "../preload/index.js"), - sandbox: false, - }, - }); - - const template: MenuItemConstructorOptions[] = [ - { - label: "View", - submenu: [ - { role: "zoomIn", accelerator: "Alt+=" }, - { role: "zoomOut", accelerator: "Alt+-" }, - { role: "resetZoom", accelerator: "Alt+r" }, - { type: "separator" }, - { role: "reload" }, - { role: "forceReload" }, - { role: "toggleDevTools" }, - { type: "separator" }, - { role: "togglefullscreen" }, - ], - }, - { - label: "App", - submenu: [ - { - label: "Settings", - click: () => mainWindow.webContents.send("open-setting"), - }, - ], - }, - ]; - - const menu = Menu.buildFromTemplate(template); - Menu.setApplicationMenu(menu); - - mainWindow.on("ready-to-show", () => { - mainWindow.show(); - }); - - mainWindow.webContents.setWindowOpenHandler((details) => { - shell.openExternal(details.url); - return { action: "deny" }; - }); - - // HMR for renderer base on electron-vite cli. - // Load the remote URL for development or the local html file for production. - if (is.dev && process.env["ELECTRON_RENDERER_URL"]) { - mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]); - } else { - mainWindow.loadFile(join(__dirname, "../renderer/index.html")); - } - - // Intial connection to arduino - SerialPort.list().then((boards) => - arduinoConnectionHandler(boards, mainWindow), - ); - - // Button module reconnect mechanism - usb.on("attach", () => { - if (!port) { - SerialPort.list().then((boards) => - arduinoConnectionHandler(boards, mainWindow), - ); - } - }); -} - -// This method will be called when Electron has finished -// initialization and is ready to create browser windows. -// Some APIs can only be used after this event occurs. -app.whenReady().then(() => { - // Set app user model id for windows - electronApp.setAppUserModelId("rmecha.my.id.sora-pemilih"); - - // Default open or close DevTools by F12 in development - // and ignore CommandOrControl + R in production. - // see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils - app.on("browser-window-created", (_, window) => { - optimizer.watchWindowShortcuts(window); - }); - - createWindow(); - - app.on("activate", function () { - // On macOS it's common to re-create a window in the app when the - // dock icon is clicked and there are no other windows open. - if (BrowserWindow.getAllWindows().length === 0) createWindow(); - }); -}); - -// Quit when all windows are closed, except on macOS. There, it's common -// for applications and their menu bar to stay active until the user quits -// explicitly with Cmd + Q. -app.on("window-all-closed", () => { - if (process.platform !== "darwin") { - app.quit(); - } -}); - -ipcMain.handle("get-server-url", () => store.get("serverURL")); -ipcMain.handle("set-server-url", (_, url) => store.set("serverURL", url)); - -// In this file you can include the rest of your app"s specific main process -// code. You can also put them in separate files and require them here. diff --git a/apps/desktop/chooser/src/main/keyboard.ts b/apps/desktop/chooser/src/main/keyboard.ts deleted file mode 100644 index 3b37bdb2..00000000 --- a/apps/desktop/chooser/src/main/keyboard.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Key, keyboard } from "@nut-tree/nut-js"; -import { BrowserWindow, Notification } from "electron"; -import { ReadlineParser, SerialPort } from "serialport"; - -export async function handleConnect( - board: Awaited>[number], - port: SerialPort | undefined, - window: BrowserWindow, -) { - port = new SerialPort({ - path: board.path, - baudRate: 9600, - autoOpen: false, - }); - - port.open((error) => { - if (error) { - new Notification({ - title: "❌ Gagal Terhubung!", - body: "Gagal membuka koneksi ke modul tombol pemilihan. Mohon periksa kembali sambungan kabel USB dengan komputer ini!", - }).show(); - - return; - } - - new Notification({ - title: "✅ Berhasil Terhubung!", - body: "Berhasil terhubung dengan tombol pemilihan!", - }).show(); - }); - - const parser = port.pipe(new ReadlineParser({ delimiter: "\r\n" })); - - parser.on("data", async (keybind) => { - if (window.isFocused()) { - switch (keybind) { - case "SORA-KEYBIND-RELOAD": - window.webContents.reload(); - break; - case "SORA-KEYBIND-ESC": - await keyboard.type(Key.Escape); - break; - case "SORA-KEYBIND-1": - await keyboard.type("1"); - break; - case "SORA-KEYBIND-2": - await keyboard.type("2"); - break; - case "SORA-KEYBIND-3": - await keyboard.type("3"); - break; - case "SORA-KEYBIND-4": - await keyboard.type("4"); - break; - case "SORA-KEYBIND-5": - await keyboard.type("5"); - break; - case "SORA-KEYBIND-ENTER": - await keyboard.type(Key.Enter); - break; - } - } - }); - - port.on("close", () => { - new Notification({ - title: "❌ Koneksi Tombol Terputus!", - body: "Koneksi dengan tombol pemilihan terputus. Mohon periksa kembali sambungan kabel USB dengan komputer ini!", - }).show(); - - port = undefined; - }); -} diff --git a/apps/desktop/chooser/src/preload/index.d.ts b/apps/desktop/chooser/src/preload/index.d.ts deleted file mode 100644 index ab1657ae..00000000 --- a/apps/desktop/chooser/src/preload/index.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ElectronAPI } from "@electron-toolkit/preload"; - -declare global { - interface Window { - electron: ElectronAPI; - api: unknown; - } -} diff --git a/apps/desktop/chooser/src/preload/index.ts b/apps/desktop/chooser/src/preload/index.ts deleted file mode 100644 index 0733ca1c..00000000 --- a/apps/desktop/chooser/src/preload/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { electronAPI } from "@electron-toolkit/preload"; -import { contextBridge } from "electron"; - -// Custom APIs for renderer -const api = {}; - -// Use `contextBridge` APIs to expose Electron APIs to -// renderer only if context isolation is enabled, otherwise -// just add to the DOM global. -if (process.contextIsolated) { - try { - contextBridge.exposeInMainWorld("electron", electronAPI); - contextBridge.exposeInMainWorld("api", api); - } catch (error) { - console.error(error); - } -} else { - // @ts-ignore (define in dts) - window.electron = electronAPI; - // @ts-ignore (define in dts) - window.api = api; -} diff --git a/apps/desktop/chooser/src/renderer/index.html b/apps/desktop/chooser/src/renderer/index.html deleted file mode 100644 index f868c5b7..00000000 --- a/apps/desktop/chooser/src/renderer/index.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - - SORA - - - - - -

- - - diff --git a/apps/desktop/chooser/src/renderer/src/App.tsx b/apps/desktop/chooser/src/renderer/src/App.tsx deleted file mode 100644 index 73032dd0..00000000 --- a/apps/desktop/chooser/src/renderer/src/App.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { useEffect, useState } from "react"; -import { - ensureHasAppSetting, - useAppSetting, -} from "@renderer/context/AppSetting"; -import { ParticipantProvider } from "@renderer/context/ParticipantContext"; -import { SettingProvider } from "@renderer/context/SettingContext"; -import { trpc } from "@renderer/utils/trpc"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { httpBatchLink } from "@trpc/client"; -import { RouterProvider, createHashRouter } from "react-router-dom"; -import superjson from "superjson"; - -import { Setting } from "@sora/ui/Setting"; - -import Main from "./routes/Main"; -import Vote from "./routes/Vote"; - -const router = createHashRouter([ - { - path: "/", - element:
, - }, - { - path: "/vote", - element: , - }, - { - path: "/setting", - element: , - }, -]); - -const App: React.FC = () => { - const { serverURL } = useAppSetting(); - - const [queryClient] = useState(() => new QueryClient()); - const [trpcClient] = useState(() => - trpc.createClient({ - transformer: superjson, - links: [ - httpBatchLink({ - url: `${serverURL as string}/api/trpc`, - }), - ], - }), - ); - - useEffect(() => { - const openSetting = () => { - location.href = "#/setting"; - }; - - window.electron.ipcRenderer.on("open-setting", openSetting); - - return () => { - window.electron.ipcRenderer.removeListener("open-setting", openSetting); - }; - }, []); - - return ( - - - - - - - - - - ); -}; - -export default ensureHasAppSetting(App); diff --git a/apps/desktop/chooser/src/renderer/src/UpperProvider.tsx b/apps/desktop/chooser/src/renderer/src/UpperProvider.tsx deleted file mode 100644 index 29f03ea7..00000000 --- a/apps/desktop/chooser/src/renderer/src/UpperProvider.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { ChakraProvider, extendTheme } from "@chakra-ui/react"; -import { AppSettingProvider } from "@renderer/context/AppSetting"; - -import App from "./App"; - -const theme = extendTheme({ - fonts: { - heading: `'Roboto', sans-serif`, - body: `'Lato', sans-serif`, - }, -}); - -const UpperProvider = () => ( - - - - - -); - -export default UpperProvider; diff --git a/apps/desktop/chooser/src/renderer/src/components/AfterVote/BerhasilMemilihDanCapJari.tsx b/apps/desktop/chooser/src/renderer/src/components/AfterVote/BerhasilMemilihDanCapJari.tsx deleted file mode 100644 index 147317f5..00000000 --- a/apps/desktop/chooser/src/renderer/src/components/AfterVote/BerhasilMemilihDanCapJari.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Box, HStack, Heading } from "@chakra-ui/react"; - -export const BerhasilMemilihDanCapJari = () => ( - - - - Data berhasil terekam! - - - Silahkan keluar dari bilik suara dan melakukan cap jari. - - - -); diff --git a/apps/desktop/chooser/src/renderer/src/components/PreScan/CantVote.tsx b/apps/desktop/chooser/src/renderer/src/components/PreScan/CantVote.tsx deleted file mode 100644 index 55257968..00000000 --- a/apps/desktop/chooser/src/renderer/src/components/PreScan/CantVote.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Box, HStack, Heading, Text } from "@chakra-ui/react"; - -const CantVote: React.FC = () => ( - - - - Tidak Di izinkan - Untuk memilih! - - - -); - -export default CantVote; diff --git a/apps/desktop/chooser/src/renderer/src/components/PreScan/ErrorOccured.tsx b/apps/desktop/chooser/src/renderer/src/components/PreScan/ErrorOccured.tsx deleted file mode 100644 index 346e1ed2..00000000 --- a/apps/desktop/chooser/src/renderer/src/components/PreScan/ErrorOccured.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Box, HStack, Heading, Text } from "@chakra-ui/react"; - -const ErrorOcurred: React.FC = () => { - return ( - - - location.reload()}> - Terjadi Kesalahan Internal - - - - Terjadi sebuah kesalahan pada aplikasi ini. - - - Mohon hubungi panitia untuk memperbaiki masalah ini. - - - - ); -}; - -export default ErrorOcurred; diff --git a/apps/desktop/chooser/src/renderer/src/components/PreScan/InvalidCandidate.tsx b/apps/desktop/chooser/src/renderer/src/components/PreScan/InvalidCandidate.tsx deleted file mode 100644 index 3cbb9b3d..00000000 --- a/apps/desktop/chooser/src/renderer/src/components/PreScan/InvalidCandidate.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Box, HStack, Heading, Text } from "@chakra-ui/react"; - -const InvalidCandidate = () => ( - - - location.reload()}> - Profil Pemilihan Tidak Valid - - - - Sekurang-kurangnya sebuah pemilihan harus memiliki dua kandidat. - - - Mohon hubungi panitia untuk memperbaiki masalah ini. - - - -); - -export default InvalidCandidate; diff --git a/apps/desktop/chooser/src/renderer/src/components/Scanner/NormalScanner.tsx b/apps/desktop/chooser/src/renderer/src/components/Scanner/NormalScanner.tsx deleted file mode 100644 index 77a2e18c..00000000 --- a/apps/desktop/chooser/src/renderer/src/components/Scanner/NormalScanner.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { useEffect, useRef } from "react"; -import { Box, HStack, Text } from "@chakra-ui/react"; -import styles from "@renderer/styles/components/Scanner.module.css"; -import { trpc } from "@renderer/utils/trpc"; -import QrScanner from "qr-scanner"; - -import { validateId } from "@sora/id-generator"; - -const NormalScanner = ({ - setInvalidQr, - checkParticipantMutation, -}: { - setInvalidQr: (invalid: boolean) => void; - checkParticipantMutation: ReturnType< - typeof trpc.participant.isParticipantAlreadyAttended.useMutation - >; -}) => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const videoRef = useRef(null!); - - useEffect(() => { - const qrScanner = new QrScanner( - videoRef.current, - async ({ data }) => { - if (data || data !== "") { - qrScanner.stop(); - - const isValidQr = validateId(data); - - if (!isValidQr) return setInvalidQr(true); - - checkParticipantMutation.mutate(data); - } - }, - { - highlightCodeOutline: true, - highlightScanRegion: true, - }, - ); - - qrScanner.start(); - - return () => { - qrScanner.destroy(); - }; - }, []); - - return ( - - - - - - - - - Scan Barcode ID Mu! - - - - - - ); -}; - -export default NormalScanner; diff --git a/apps/desktop/chooser/src/renderer/src/components/Scanner/index.tsx b/apps/desktop/chooser/src/renderer/src/components/Scanner/index.tsx deleted file mode 100644 index 70c60eff..00000000 --- a/apps/desktop/chooser/src/renderer/src/components/Scanner/index.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { useCallback, useState } from "react"; -import { useParticipant } from "@renderer/context/ParticipantContext"; -import { trpc } from "@renderer/utils/trpc"; -import { Navigate } from "react-router-dom"; - -import { Loading } from "@sora/ui/Loading"; - -import UniversalErrorHandler from "../UniversalErrorHandler"; -import NormalScanner from "./NormalScanner"; - -const Scanner: React.FC = () => { - const { qrId, setQRCode } = useParticipant(); - - const [isQrInvalid, setInvalidQr] = useState(false); - - const checkParticipantMutation = - trpc.participant.isParticipantAlreadyAttended.useMutation({ - onSuccess() { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - setQRCode(checkParticipantMutation.variables!); - }, - }); - - const setIsQrValid = useCallback( - (invalid: boolean) => setInvalidQr(invalid), - [], - ); - - if (qrId) return ; - - if (checkParticipantMutation.isLoading) - return ; - - if (isQrInvalid || checkParticipantMutation.isError) - return ( - - ); - return ( - - ); -}; - -export default Scanner; diff --git a/apps/desktop/chooser/src/renderer/src/components/UniversalErrorHandler.tsx b/apps/desktop/chooser/src/renderer/src/components/UniversalErrorHandler.tsx deleted file mode 100644 index 95fcbe81..00000000 --- a/apps/desktop/chooser/src/renderer/src/components/UniversalErrorHandler.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { Box, HStack, Heading } from "@chakra-ui/react"; - -const UniversalError = ({ - title, - message, -}: { - title: string; - message: string; -}) => { - return ( - - - location.reload()} - > - {title} - - - {message} - - - - ); -}; - -export default UniversalError; diff --git a/apps/desktop/chooser/src/renderer/src/context/AppSetting.tsx b/apps/desktop/chooser/src/renderer/src/context/AppSetting.tsx deleted file mode 100644 index 9a5ef077..00000000 --- a/apps/desktop/chooser/src/renderer/src/context/AppSetting.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { createContext, useContext, useEffect, useMemo, useState } from "react"; - -import { Setting } from "@sora/ui/Setting"; - -interface IAppSetting { - serverURL?: string; -} - -export const AppSettingContext = createContext({} as IAppSetting); - -export const AppSettingProvider = ({ - children, -}: { - children: React.ReactNode; -}) => { - const [serverURL, setServerUrlState] = useState(); - - useEffect(() => { - const composeAsync = async () => { - const storeValue = await window.electron.ipcRenderer.invoke( - "get-server-url", - ); - - setServerUrlState(storeValue); - }; - - composeAsync(); - }, []); - - const valueProps = useMemo(() => ({ serverURL }), [serverURL]); - - return ( - - {children} - - ); -}; - -export const useAppSetting = () => useContext(AppSettingContext) as IAppSetting; - -// eslint-disable-next-line react/display-name -export const ensureHasAppSetting = (Element: React.FC) => () => { - const { serverURL } = useAppSetting(); - - if (!serverURL) return ; - - return ; -}; diff --git a/apps/desktop/chooser/src/renderer/src/context/ParticipantContext.tsx b/apps/desktop/chooser/src/renderer/src/context/ParticipantContext.tsx deleted file mode 100644 index 19d720a1..00000000 --- a/apps/desktop/chooser/src/renderer/src/context/ParticipantContext.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { - createContext, - useCallback, - useContext, - useMemo, - useState, -} from "react"; -import { Navigate } from "react-router-dom"; - -export interface IParticipantContext { - qrId: string | null; - setQRCode: (qr: string | null) => void; -} - -export const ParticipantContext = createContext( - {} as IParticipantContext, -); - -export const ParticipantProvider = ({ - children, -}: { - children: React.ReactNode; -}) => { - const [qrId, setQrId] = useState(null); - - const setQRCode = useCallback((qr: string | null) => setQrId(qr), []); - - const propsValue = useMemo( - () => ({ - qrId, - setQRCode, - }), - [qrId], - ); - - return ( - - {children} - - ); -}; - -export const useParticipant = () => - useContext(ParticipantContext) as IParticipantContext; - -// eslint-disable-next-line react/display-name -export const justEnsureQrIDExist = (Element: React.FC) => () => { - const { qrId } = useParticipant(); - - if (!qrId) return ; - - return ; -}; diff --git a/apps/desktop/chooser/src/renderer/src/context/SettingContext.tsx b/apps/desktop/chooser/src/renderer/src/context/SettingContext.tsx deleted file mode 100644 index 78d26e74..00000000 --- a/apps/desktop/chooser/src/renderer/src/context/SettingContext.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { createContext, useContext, useMemo, useState } from "react"; -import { useToast } from "@chakra-ui/react"; -import { trpc, type RouterOutput } from "@renderer/utils/trpc"; -import { DateTime } from "luxon"; - -import { useParticipant } from "./ParticipantContext"; - -interface ISettingContext { - canVoteNow: boolean; - isLoading: boolean; - isError: boolean; - isCandidatesExist: boolean; - candidates: RouterOutput["candidate"]["candidateList"] | undefined; -} - -export const SettingContext = createContext( - {} as ISettingContext, -); - -export const SettingProvider = ({ - children, -}: { - children: React.ReactNode; -}) => { - const toast = useToast(); - - const { qrId, setQRCode } = useParticipant(); - - const [canVoteNow, setCanVote] = useState(false); - - const candidateQuery = trpc.candidate.candidateList.useQuery(undefined, { - refetchOnWindowFocus: false, - - onError(error) { - toast({ - description: `Error: ${error.message}`, - status: "error", - duration: 5000, - position: "top-right", - }); - }, - }); - - const settingsQuery = trpc.settings.getSettings.useQuery(undefined, { - refetchInterval: 2500, - refetchIntervalInBackground: true, - - onSuccess(result) { - const waktuMulai = result.startTime - ? DateTime.fromJSDate(result.startTime).toLocal().toJSDate().getTime() - : null; - const waktuSelesai = result.endTime - ? DateTime.fromJSDate(result.endTime).toLocal().toJSDate().getTime() - : null; - - const currentTime = new Date().getTime(); - - const canVote = - (waktuMulai as number) <= currentTime && - (waktuSelesai as number) >= currentTime && - result.canVote; - - setCanVote(canVote); - - if (!canVote && qrId) setQRCode(null); - - if (candidateQuery.isError) candidateQuery.refetch(); - }, - - onError(error) { - toast({ - description: `Error: ${error.message}`, - status: "error", - duration: 5000, - position: "top-right", - }); - }, - }); - - const isCandidatesExist = useMemo( - () => (candidateQuery.data && candidateQuery.data.length > 1) || false, - [candidateQuery.data], - ); - - const isLoading = useMemo( - () => candidateQuery.isLoading || settingsQuery.isLoading, - [candidateQuery.isLoading, settingsQuery.isLoading], - ); - - const isError = useMemo( - () => candidateQuery.isError || settingsQuery.isError, - [candidateQuery.isError, settingsQuery.isError], - ); - - return ( - - {children} - - ); -}; - -export const useSetting = () => useContext(SettingContext) as ISettingContext; diff --git a/apps/desktop/chooser/src/renderer/src/env.d.ts b/apps/desktop/chooser/src/renderer/src/env.d.ts deleted file mode 100644 index 11f02fe2..00000000 --- a/apps/desktop/chooser/src/renderer/src/env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/apps/desktop/chooser/src/renderer/src/main.tsx b/apps/desktop/chooser/src/renderer/src/main.tsx deleted file mode 100644 index 1290087c..00000000 --- a/apps/desktop/chooser/src/renderer/src/main.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from "react"; -import ReactDOM from "react-dom/client"; - -import "@fontsource/lato"; -import "@fontsource/sora"; -import "@fontsource/roboto"; -import UpperProvider from "./UpperProvider"; - -ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - - , -); diff --git a/apps/desktop/chooser/src/renderer/src/routes/Main.tsx b/apps/desktop/chooser/src/renderer/src/routes/Main.tsx deleted file mode 100644 index de24a2f2..00000000 --- a/apps/desktop/chooser/src/renderer/src/routes/Main.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import CantVote from "@renderer/components/PreScan/CantVote"; -import ErrorOcurred from "@renderer/components/PreScan/ErrorOccured"; -import InvalidCandidate from "@renderer/components/PreScan/InvalidCandidate"; -import Scanner from "@renderer/components/Scanner"; -import { useSetting } from "@renderer/context/SettingContext"; - -import { Loading } from "@sora/ui/Loading"; - -const Main: React.FC = () => { - const { isLoading, isError, isCandidatesExist, canVoteNow } = useSetting(); - - if (isError) return ; - - if (isLoading && !isError) - return ; - - if (!isLoading && !canVoteNow && !isError) return ; - - if (!isLoading && canVoteNow && !isCandidatesExist && !isError) - return ; - - return ; -}; - -export default Main; diff --git a/apps/desktop/chooser/src/renderer/src/routes/Vote.tsx b/apps/desktop/chooser/src/renderer/src/routes/Vote.tsx deleted file mode 100644 index 71cd3355..00000000 --- a/apps/desktop/chooser/src/renderer/src/routes/Vote.tsx +++ /dev/null @@ -1,306 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - // Alert dialog - AlertDialog, - AlertDialogBody, - AlertDialogContent, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogOverlay, - Button, - Card, - CardBody, - CardFooter, - HStack, - Heading, - Image, - Stack, - Text, - VStack, - useDisclosure, -} from "@chakra-ui/react"; -import { BerhasilMemilihDanCapJari } from "@renderer/components/AfterVote/BerhasilMemilihDanCapJari"; -import UniversalError from "@renderer/components/UniversalErrorHandler"; -import { useAppSetting } from "@renderer/context/AppSetting"; -import { - justEnsureQrIDExist, - useParticipant, - type IParticipantContext, -} from "@renderer/context/ParticipantContext"; -import { useSetting } from "@renderer/context/SettingContext"; -import { trpc } from "@renderer/utils/trpc"; -import { useNavigate } from "react-router-dom"; - -import { Loading } from "@sora/ui/Loading"; - -const Minggat = ({ qrId, setQRCode }: IParticipantContext) => { - const navigate = useNavigate(); - - useEffect(() => { - if (qrId) { - setQRCode(null); - navigate("/", { replace: true }); - } - }, []); - - return <>; -}; - -const Vote = () => { - const { serverURL } = useAppSetting(); - const { qrId, setQRCode } = useParticipant(); - const { isLoading, isError, canVoteNow, candidates } = useSetting(); - - const { isOpen, onOpen, onClose } = useDisclosure(); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const cancelRef = useRef(null!); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const sendRef = useRef(null!); - - // Untuk keperluan pemilihan - const [currentID, setID] = useState(null); - - const candidateMutation = trpc.candidate.upvote.useMutation({ - onSuccess() { - setTimeout(() => { - setQRCode(null); - }, 12_000); - }, - }); - - const participantQuery = trpc.participant.getParticipantStatus.useQuery( - qrId as string, - { - refetchInterval: 2500, - }, - ); - - const cannotPushKey = useMemo( - () => - !qrId || - !canVoteNow || - candidateMutation.isSuccess || - candidateMutation.isError || - candidateMutation.isLoading || - (candidates && candidates.length === 0), - [ - qrId, - canVoteNow, - candidateMutation.isError, - candidateMutation.isLoading, - candidateMutation.isSuccess, - ], - ); - - const chooseCandidate = useCallback(() => { - if (qrId) { - sendRef.current.setAttribute("disabled", "disabled"); - candidateMutation.mutate({ - id: currentID as number, - qrId, - }); - } - }, [candidateMutation, currentID]); - - const getNama = () => { - const currentCandidate = - candidates && candidates?.find((p) => p.id === currentID); - - return currentCandidate?.name; - }; - - useEffect(() => { - const triggerBox = (index: number) => { - const candidateData = - candidates && candidates.length > 0 && candidates[index]; - - if (!isOpen && candidateData) { - setID(candidateData.id); - onOpen(); - } - }; - - const onKeydown = (e: KeyboardEvent) => { - if (cannotPushKey) return; - - switch (e.key) { - case "Escape": - if (isOpen) onClose(); - break; - - case "1": - triggerBox(0); - break; - - case "2": - triggerBox(1); - break; - - case "3": - triggerBox(2); - break; - - case "4": - triggerBox(3); - break; - - case "5": - triggerBox(4); - break; - - case "Enter": - if (isOpen) chooseCandidate(); - break; - } - }; - - window.addEventListener("keyup", onKeydown); - - return () => { - window.removeEventListener("keyup", onKeydown); - }; - }, [cannotPushKey, isOpen]); - - if (isLoading) - return ; - - if (!canVoteNow || isError) - return ; - - if (candidateMutation.isSuccess) return ; - - if (candidateMutation.isError) - return ( - - ); - - if ( - participantQuery.isFetched && - !participantQuery.data?.alreadyAttended && - candidateMutation.isIdle - ) - return ( - - ); - - if ( - participantQuery.isFetched && - participantQuery.data?.alreadyChoosing && - candidateMutation.isIdle - ) - return ( - - ); - - return ( - - - - Pilih Kandidatmu! - - - - {candidates?.map((kandidat, idx) => ( - - - {`Gambar - - - Nomor Urut {++idx} - - - {kandidat.name} - - - - - - - - - ))} - - - { - if (!candidateMutation.isLoading) { - setID(null); - onClose(); - } - }} - > - - - - Pilih Kandidat - - - - Apakah anda yakin untuk memilih kandidat atas nama {getNama()}? - - - - - - - - - - - ); -}; - -export default justEnsureQrIDExist(Vote); diff --git a/apps/desktop/chooser/src/renderer/src/styles/components/Scanner.module.css b/apps/desktop/chooser/src/renderer/src/styles/components/Scanner.module.css deleted file mode 100644 index fa8989e7..00000000 --- a/apps/desktop/chooser/src/renderer/src/styles/components/Scanner.module.css +++ /dev/null @@ -1,4 +0,0 @@ -.video { - height: 100%; - width: auto; -} diff --git a/apps/desktop/chooser/src/renderer/src/utils/trpc.ts b/apps/desktop/chooser/src/renderer/src/utils/trpc.ts deleted file mode 100644 index 921c3dfd..00000000 --- a/apps/desktop/chooser/src/renderer/src/utils/trpc.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createTRPCReact } from "@trpc/react-query"; -import type { inferRouterOutputs } from "@trpc/server"; - -import type { AppRouter } from "@sora/api"; - -export const trpc = createTRPCReact(); - -export type RouterOutput = inferRouterOutputs; diff --git a/apps/desktop/chooser/tsconfig.json b/apps/desktop/chooser/tsconfig.json deleted file mode 100644 index 155ebaa6..00000000 --- a/apps/desktop/chooser/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "files": [], - "references": [ - { "path": "./tsconfig.node.json" }, - { "path": "./tsconfig.web.json" } - ] -} diff --git a/apps/desktop/chooser/tsconfig.node.json b/apps/desktop/chooser/tsconfig.node.json deleted file mode 100644 index 2178c605..00000000 --- a/apps/desktop/chooser/tsconfig.node.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", - "include": ["electron.vite.config.*", "src/main/*", "src/preload/*"], - "compilerOptions": { - "composite": true, - "types": ["electron-vite/node"] - } -} diff --git a/apps/desktop/chooser/tsconfig.web.json b/apps/desktop/chooser/tsconfig.web.json deleted file mode 100644 index ff380cc0..00000000 --- a/apps/desktop/chooser/tsconfig.web.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "extends": "@electron-toolkit/tsconfig/tsconfig.web.json", - "include": [ - "src/renderer/src/env.d.ts", - "src/renderer/src/**/*", - "src/renderer/src/**/*.tsx", - "src/preload/*.d.ts" - ], - "compilerOptions": { - "composite": true, - "jsx": "react-jsx", - "baseUrl": ".", - "paths": { - "@renderer/*": ["src/renderer/src/*"], - "~/*": ["../sora/src/*"] - } - } -} diff --git a/apps/web/README.md b/apps/nextjs/README.md similarity index 97% rename from apps/web/README.md rename to apps/nextjs/README.md index cc405267..437b0b8e 100644 --- a/apps/web/README.md +++ b/apps/nextjs/README.md @@ -10,7 +10,7 @@ If you are not familiar with the different technologies used in this project, pl - [Next.js](https://nextjs.org) - [NextAuth.js](https://next-auth.js.org) -- [Prisma](https://prisma.io) +- [Drizzle](https://orm.drizzle.team) - [Tailwind CSS](https://tailwindcss.com) - [tRPC](https://trpc.io) diff --git a/apps/nextjs/eslint.config.js b/apps/nextjs/eslint.config.js new file mode 100644 index 00000000..859452de --- /dev/null +++ b/apps/nextjs/eslint.config.js @@ -0,0 +1,14 @@ +import baseConfig, { restrictEnvAccess } from "@sora-vp/eslint-config/base"; +import nextjsConfig from "@sora-vp/eslint-config/nextjs"; +import reactConfig from "@sora-vp/eslint-config/react"; + +/** @type {import('typescript-eslint').Config} */ +export default [ + { + ignores: [".next/**"], + }, + ...baseConfig, + ...reactConfig, + ...nextjsConfig, + ...restrictEnvAccess, +]; diff --git a/apps/web/next.config.mjs b/apps/nextjs/next.config.js similarity index 56% rename from apps/web/next.config.mjs rename to apps/nextjs/next.config.js index d72867ae..33d1111a 100644 --- a/apps/web/next.config.mjs +++ b/apps/nextjs/next.config.js @@ -1,24 +1,22 @@ -// Importing env files here to validate on build -import path from "path"; import { fileURLToPath } from "url"; +import createJiti from "jiti"; -import "./src/env.mjs"; -import "@sora/auth/env.mjs"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); +// Import env files to validate at build time. Use jiti so we can load .ts files in here. +createJiti(fileURLToPath(import.meta.url))("./src/env"); /** @type {import("next").NextConfig} */ const config = { reactStrictMode: true, + /** Enables hot reloading for local packages without a build step */ transpilePackages: [ - "@sora/api", - "@sora/auth", - "@sora/db", - "@sora/id-generator", - "@sora/ui", + "@sora-vp/api", + "@sora-vp/auth", + "@sora-vp/db", + "@sora-vp/ui", + "@sora-vp/validators", ], + /** We already do linting and typechecking as separate tasks in CI */ eslint: { ignoreDuringBuilds: true }, typescript: { ignoreBuildErrors: true }, diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json new file mode 100644 index 00000000..4afc4437 --- /dev/null +++ b/apps/nextjs/package.json @@ -0,0 +1,50 @@ +{ + "name": "@sora-vp/nextjs", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "yarn with-env next build", + "clean": "git clean -xdf .next .turbo node_modules", + "dev": "yarn with-env next dev", + "format": "prettier --check . --ignore-path ../../.gitignore", + "lint": "eslint", + "start": "yarn with-env next start", + "typecheck": "tsc --noEmit", + "with-env": "dotenv -e ../../.env --" + }, + "dependencies": { + "@sora-vp/api": "*", + "@sora-vp/auth": "*", + "@sora-vp/db": "*", + "@sora-vp/ui": "*", + "@sora-vp/validators": "*", + "@t3-oss/env-nextjs": "^0.10.1", + "@tanstack/react-query": "^5.35.1", + "@trpc/client": "11.0.0-rc.364", + "@trpc/react-query": "11.0.0-rc.364", + "@trpc/server": "11.0.0-rc.364", + "geist": "^1.3.0", + "next": "^14.2.3", + "react": "18.3.1", + "react-dom": "18.3.1", + "superjson": "2.2.1", + "zod": "^3.23.6" + }, + "devDependencies": { + "@sora-vp/eslint-config": "*", + "@sora-vp/prettier-config": "*", + "@sora-vp/tailwind-config": "*", + "@sora-vp/tsconfig": "*", + "@types/node": "^20.12.9", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.0", + "dotenv-cli": "^7.4.1", + "eslint": "^9.2.0", + "jiti": "^1.21.0", + "prettier": "^3.2.5", + "tailwindcss": "^3.4.3", + "typescript": "^5.4.5" + }, + "prettier": "@sora-vp/prettier-config" +} diff --git a/apps/nextjs/postcss.config.cjs b/apps/nextjs/postcss.config.cjs new file mode 100644 index 00000000..ee5f90b3 --- /dev/null +++ b/apps/nextjs/postcss.config.cjs @@ -0,0 +1,5 @@ +module.exports = { + plugins: { + tailwindcss: {}, + }, +}; diff --git a/apps/nextjs/public/favicon.ico b/apps/nextjs/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..f0058b404f98275b58117d309a8b3753c54aa619 GIT binary patch literal 103027 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 + + + + + + + + + + + + diff --git a/apps/nextjs/src/app/_components/auth-showcase.tsx b/apps/nextjs/src/app/_components/auth-showcase.tsx new file mode 100644 index 00000000..4f605fd1 --- /dev/null +++ b/apps/nextjs/src/app/_components/auth-showcase.tsx @@ -0,0 +1,42 @@ +import { auth, signIn, signOut } from "@sora-vp/auth"; +import { Button } from "@sora-vp/ui/button"; + +export async function AuthShowcase() { + const session = await auth(); + + if (!session) { + return ( +
+ +
+ ); + } + + return ( +
+

+ Logged in as {session.user.name} +

+ +
+ +
+
+ ); +} diff --git a/apps/nextjs/src/app/_components/posts.tsx b/apps/nextjs/src/app/_components/posts.tsx new file mode 100644 index 00000000..6b82d9f8 --- /dev/null +++ b/apps/nextjs/src/app/_components/posts.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { use } from "react"; + +import type { RouterOutputs } from "@sora-vp/api"; +import { cn } from "@sora-vp/ui"; +import { Button } from "@sora-vp/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, + useForm, +} from "@sora-vp/ui/form"; +import { Input } from "@sora-vp/ui/input"; +import { toast } from "@sora-vp/ui/toast"; +import { CreatePostSchema } from "@sora-vp/validators"; + +import { api } from "~/trpc/react"; + +export function CreatePostForm() { + const form = useForm({ + schema: CreatePostSchema, + defaultValues: { + content: "", + title: "", + }, + }); + + const utils = api.useUtils(); + const createPost = api.post.create.useMutation({ + onSuccess: async () => { + form.reset(); + await utils.post.invalidate(); + }, + onError: (err) => { + toast.error( + err.data?.code === "UNAUTHORIZED" + ? "You must be logged in to post" + : "Failed to create post", + ); + }, + }); + + return ( +
+ { + createPost.mutate(data); + })} + > + ( + + + + + + + )} + /> + ( + + + + + + + )} + /> + + + + ); +} + +export function PostList(props: { + posts: Promise; +}) { + // TODO: Make `useSuspenseQuery` work without having to pass a promise from RSC + const initialData = use(props.posts); + const { data: posts } = api.post.all.useQuery(undefined, { + initialData, + }); + + if (posts.length === 0) { + return ( +
+ + + + +
+

No posts yet

+
+
+ ); + } + + return ( +
+ {posts.map((p) => { + return ; + })} +
+ ); +} + +export function PostCard(props: { + post: RouterOutputs["post"]["all"][number]; +}) { + const utils = api.useUtils(); + const deletePost = api.post.delete.useMutation({ + onSuccess: async () => { + await utils.post.invalidate(); + }, + onError: (err) => { + toast.error( + err.data?.code === "UNAUTHORIZED" + ? "You must be logged in to delete a post" + : "Failed to delete post", + ); + }, + }); + + return ( +
+
+

{props.post.title}

+

{props.post.content}

+
+
+ +
+
+ ); +} + +export function PostCardSkeleton(props: { pulse?: boolean }) { + const { pulse = true } = props; + return ( +
+
+

+   +

+

+   +

+
+
+ ); +} diff --git a/apps/nextjs/src/app/api/auth/[...nextauth]/route.ts b/apps/nextjs/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 00000000..c6670786 --- /dev/null +++ b/apps/nextjs/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +export { GET, POST } from "@sora-vp/auth"; + +export const runtime = "edge"; diff --git a/apps/nextjs/src/app/api/trpc/[trpc]/route.ts b/apps/nextjs/src/app/api/trpc/[trpc]/route.ts new file mode 100644 index 00000000..64904c31 --- /dev/null +++ b/apps/nextjs/src/app/api/trpc/[trpc]/route.ts @@ -0,0 +1,45 @@ +import { appRouter, createTRPCContext } from "@sora-vp/api"; +import { auth } from "@sora-vp/auth"; +import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; + +export const runtime = "edge"; + +/** + * Configure basic CORS headers + * You should extend this to match your needs + */ +const setCorsHeaders = (res: Response) => { + res.headers.set("Access-Control-Allow-Origin", "*"); + res.headers.set("Access-Control-Request-Method", "*"); + res.headers.set("Access-Control-Allow-Methods", "OPTIONS, GET, POST"); + res.headers.set("Access-Control-Allow-Headers", "*"); +}; + +export const OPTIONS = () => { + const response = new Response(null, { + status: 204, + }); + setCorsHeaders(response); + return response; +}; + +const handler = auth(async (req) => { + const response = await fetchRequestHandler({ + endpoint: "/api/trpc", + router: appRouter, + req, + createContext: () => + createTRPCContext({ + session: req.auth, + headers: req.headers, + }), + onError({ error, path }) { + console.error(`>>> tRPC Error on '${path}'`, error); + }, + }); + + setCorsHeaders(response); + return response; +}); + +export { handler as GET, handler as POST }; diff --git a/apps/nextjs/src/app/globals.css b/apps/nextjs/src/app/globals.css new file mode 100644 index 00000000..b9d992f8 --- /dev/null +++ b/apps/nextjs/src/app/globals.css @@ -0,0 +1,50 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + --primary: 327 66% 69%; + --primary-foreground: 337 65.5% 17.1%; + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + --destructive: 0 72.22% 50.59%; + --destructive-foreground: 0 0% 98%; + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 240 5% 64.9%; + --radius: 0.5rem; + } + + .dark { + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 327 66% 69%; + --primary-foreground: 337 65.5% 17.1%; + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 85.7% 97.3%; + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 240 4.9% 83.9%; + } +} diff --git a/apps/nextjs/src/app/layout.tsx b/apps/nextjs/src/app/layout.tsx new file mode 100644 index 00000000..74d0d61c --- /dev/null +++ b/apps/nextjs/src/app/layout.tsx @@ -0,0 +1,63 @@ +import type { Metadata, Viewport } from "next"; +import { GeistMono } from "geist/font/mono"; +import { GeistSans } from "geist/font/sans"; + +import { cn } from "@sora-vp/ui"; +import { ThemeProvider, ThemeToggle } from "@sora-vp/ui/theme"; +import { Toaster } from "@sora-vp/ui/toast"; + +import { TRPCReactProvider } from "~/trpc/react"; + +import "~/app/globals.css"; + +import { env } from "~/env"; + +export const metadata: Metadata = { + metadataBase: new URL( + env.VERCEL_ENV === "production" + ? "https://turbo.t3.gg" + : "http://localhost:3000", + ), + title: "Create T3 Turbo", + description: "Simple monorepo with shared backend for web & mobile apps", + openGraph: { + title: "Create T3 Turbo", + description: "Simple monorepo with shared backend for web & mobile apps", + url: "https://create-t3-turbo.vercel.app", + siteName: "Create T3 Turbo", + }, + twitter: { + card: "summary_large_image", + site: "@jullerino", + creator: "@jullerino", + }, +}; + +export const viewport: Viewport = { + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "white" }, + { media: "(prefers-color-scheme: dark)", color: "black" }, + ], +}; + +export default function RootLayout(props: { children: React.ReactNode }) { + return ( + + + + {props.children} +
+ +
+ +
+ + + ); +} diff --git a/apps/nextjs/src/app/page.tsx b/apps/nextjs/src/app/page.tsx new file mode 100644 index 00000000..5c61c1ab --- /dev/null +++ b/apps/nextjs/src/app/page.tsx @@ -0,0 +1,42 @@ +import { Suspense } from "react"; + +import { api } from "~/trpc/server"; +import { AuthShowcase } from "./_components/auth-showcase"; +import { + CreatePostForm, + PostCardSkeleton, + PostList, +} from "./_components/posts"; + +export const runtime = "edge"; + +export default function HomePage() { + // You can await this here if you don't want to show Suspense fallback below + const posts = api.post.all(); + + return ( +
+
+

+ Create T3 Turbo +

+ + + +
+ + + + +
+ } + > + + +
+ +
+ ); +} diff --git a/apps/nextjs/src/env.ts b/apps/nextjs/src/env.ts new file mode 100644 index 00000000..6d2cc7db --- /dev/null +++ b/apps/nextjs/src/env.ts @@ -0,0 +1,44 @@ +/* eslint-disable no-restricted-properties */ +import { env as authEnv } from "@sora-vp/auth/env"; +import { createEnv } from "@t3-oss/env-nextjs"; +import { vercel } from "@t3-oss/env-nextjs/presets"; +import { z } from "zod"; + +export const env = createEnv({ + extends: [authEnv, vercel()], + shared: { + NODE_ENV: z + .enum(["development", "production", "test"]) + .default("development"), + }, + /** + * Specify your server-side environment variables schema here. + * This way you can ensure the app isn't built with invalid env vars. + */ + server: { + DB_HOST: z.string(), + DB_NAME: z.string(), + DB_PASSWORD: z.string(), + DB_USERNAME: z.string(), + }, + + /** + * Specify your client-side environment variables schema here. + * For them to be exposed to the client, prefix them with `NEXT_PUBLIC_`. + */ + client: { + // NEXT_PUBLIC_CLIENTVAR: z.string(), + }, + /** + * Destructure all variables from `process.env` to make sure they aren't tree-shaken away. + */ + experimental__runtimeEnv: { + NODE_ENV: process.env.NODE_ENV, + + // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR, + }, + skipValidation: + !!process.env.CI || + !!process.env.SKIP_ENV_VALIDATION || + process.env.npm_lifecycle_event === "lint", +}); diff --git a/apps/nextjs/src/middleware.ts b/apps/nextjs/src/middleware.ts new file mode 100644 index 00000000..321b4b52 --- /dev/null +++ b/apps/nextjs/src/middleware.ts @@ -0,0 +1,11 @@ +export { auth as middleware } from "@sora-vp/auth"; + +// Or like this if you need to do something here. +// export default auth((req) => { +// console.log(req.auth) // { session: { user: { ... } } } +// }) + +// Read more: https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher +export const config = { + matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"], +}; diff --git a/apps/nextjs/src/trpc/react.tsx b/apps/nextjs/src/trpc/react.tsx new file mode 100644 index 00000000..b574b98f --- /dev/null +++ b/apps/nextjs/src/trpc/react.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useState } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { loggerLink, unstable_httpBatchStreamLink } from "@trpc/client"; +import { createTRPCReact } from "@trpc/react-query"; +import SuperJSON from "superjson"; + +import type { AppRouter } from "@sora-vp/api"; + +import { env } from "~/env"; + +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + // With SSR, we usually want to set some default staleTime + // above 0 to avoid refetching immediately on the client + staleTime: 30 * 1000, + }, + }, + }); + +let clientQueryClientSingleton: QueryClient | undefined = undefined; +const getQueryClient = () => { + if (typeof window === "undefined") { + // Server: always make a new query client + return createQueryClient(); + } else { + // Browser: use singleton pattern to keep the same query client + return (clientQueryClientSingleton ??= createQueryClient()); + } +}; + +export const api = createTRPCReact(); + +export function TRPCReactProvider(props: { children: React.ReactNode }) { + const queryClient = getQueryClient(); + + const [trpcClient] = useState(() => + api.createClient({ + links: [ + loggerLink({ + enabled: (op) => + env.NODE_ENV === "development" || + (op.direction === "down" && op.result instanceof Error), + }), + unstable_httpBatchStreamLink({ + transformer: SuperJSON, + url: getBaseUrl() + "/api/trpc", + headers() { + const headers = new Headers(); + headers.set("x-trpc-source", "nextjs-react"); + return headers; + }, + }), + ], + }), + ); + + return ( + + + {props.children} + + + ); +} + +const getBaseUrl = () => { + if (typeof window !== "undefined") return window.location.origin; + if (env.VERCEL_URL) return `https://${env.VERCEL_URL}`; + // eslint-disable-next-line no-restricted-properties + return `http://localhost:${process.env.PORT ?? 3000}`; +}; diff --git a/apps/nextjs/src/trpc/server.ts b/apps/nextjs/src/trpc/server.ts new file mode 100644 index 00000000..bcf09979 --- /dev/null +++ b/apps/nextjs/src/trpc/server.ts @@ -0,0 +1,20 @@ +import { cache } from "react"; +import { headers } from "next/headers"; +import { createCaller, createTRPCContext } from "@sora-vp/api"; +import { auth } from "@sora-vp/auth"; + +/** + * This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when + * handling a tRPC call from a React Server Component. + */ +const createContext = cache(async () => { + const heads = new Headers(headers()); + heads.set("x-trpc-source", "rsc"); + + return createTRPCContext({ + session: await auth(), + headers: heads, + }); +}); + +export const api = createCaller(createContext); diff --git a/apps/nextjs/tailwind.config.ts b/apps/nextjs/tailwind.config.ts new file mode 100644 index 00000000..acb3a1de --- /dev/null +++ b/apps/nextjs/tailwind.config.ts @@ -0,0 +1,18 @@ +import type { Config } from "tailwindcss"; +import baseConfig from "@sora-vp/tailwind-config/web"; +import { fontFamily } from "tailwindcss/defaultTheme"; + +export default { + // We need to append the path to the UI package to the content array so that + // those classes are included correctly. + content: [...baseConfig.content, "../../packages/ui/**/*.{ts,tsx}"], + presets: [baseConfig], + theme: { + extend: { + fontFamily: { + sans: ["var(--font-geist-sans)", ...fontFamily.sans], + mono: ["var(--font-geist-mono)", ...fontFamily.mono], + }, + }, + }, +} satisfies Config; diff --git a/apps/nextjs/tsconfig.json b/apps/nextjs/tsconfig.json new file mode 100644 index 00000000..05ecd854 --- /dev/null +++ b/apps/nextjs/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "@sora-vp/tsconfig/base.json", + "compilerOptions": { + "lib": ["es2022", "dom", "dom.iterable"], + "jsx": "preserve", + "baseUrl": ".", + "paths": { + "~/*": ["./src/*"] + }, + "plugins": [{ "name": "next" }], + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json", + "module": "esnext" + }, + "include": [".", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/apps/processor/nodemon.json b/apps/processor/nodemon.json deleted file mode 100644 index a551c7ae..00000000 --- a/apps/processor/nodemon.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "watch": ["src"], - "ext": "ts,json", - "exec": "ts-node" -} diff --git a/apps/processor/package.json b/apps/processor/package.json deleted file mode 100644 index 144a53f9..00000000 --- a/apps/processor/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "@sora/processor", - "version": "2.1.0", - "private": true, - "description": "Consumer yang memroses data yang datang dari pemilih untuk memilih kandidatnya", - "main": "dist/index.js", - "scripts": { - "build": "tsup", - "lint": "eslint src/", - "type-check": "tsc --noEmit", - "dev": "yarn with-env nodemon src/index.ts", - "with-env": "dotenv -e ../../.env --" - }, - "author": "Ezra Khairan Permana", - "license": "GPL-3.0", - "devDependencies": { - "@types/amqplib": "^0.10.1", - "@types/luxon": "^3.3.2", - "@typescript-eslint/eslint-plugin": "latest", - "@typescript-eslint/parser": "latest", - "dotenv-cli": "^7.2.1", - "eslint": "latest", - "nodemon": "^3.0.1", - "ts-node": "^10.9.1", - "tsup": "^7.2.0", - "tsx": "^3.12.8", - "typescript": "^5.2.2" - }, - "dependencies": { - "@sora/api": "^0.1.0", - "@sora/db": "^0.1.0", - "@sora/id-generator": "^0.1.0", - "@trpc/client": "^10.38.1", - "@trpc/server": "^10.38.1", - "amqplib": "^0.10.3", - "dotenv": "^16.1.3", - "luxon": "^3.4.3", - "pino": "^8.11.0", - "pino-pretty": "^10.0.0", - "superjson": "1.13.1", - "zod": "^3.22.2" - } -} diff --git a/apps/processor/src/canVoteNow.ts b/apps/processor/src/canVoteNow.ts deleted file mode 100644 index 92ca6782..00000000 --- a/apps/processor/src/canVoteNow.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { DateTime } from "luxon"; - -import type { RouterOutputs } from "./trpc"; - -type TSettings = RouterOutputs["settings"]["getSettings"]; - -const getTimePermission = (settings: TSettings) => { - const currentTime = DateTime.now().toUTC().toJSDate().getTime(); - - const timeConfig = { - mulai: settings?.startTime ? settings.startTime.getTime() : false, - selesai: settings?.endTime ? settings.endTime.getTime() : false, - }; - - return { - isPermittedByTime: - // Start - (timeConfig.mulai - ? (timeConfig.mulai as number) <= currentTime - : false) && - // End - (timeConfig.selesai - ? (timeConfig.selesai as number) >= currentTime - : false), - }; -}; - -export const canVoteNow = (settings: TSettings) => { - const { isPermittedByTime } = getTimePermission(settings); - - return isPermittedByTime && settings.canVote; -}; diff --git a/apps/processor/src/env.ts b/apps/processor/src/env.ts deleted file mode 100644 index 804d8783..00000000 --- a/apps/processor/src/env.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { createEnv } from "@t3-oss/env-core"; -import { z } from "zod"; - -export const env = createEnv({ - clientPrefix: "", - client: {}, - - server: { - AMQP_URL: z.string().url(), - TRPC_URL: z.string().url(), - }, - runtimeEnv: { - AMQP_URL: process.env.AMQP_URL, - TRPC_URL: process.env.TRPC_URL ?? "http://127.0.0.1:3000/api/trpc", - }, - skipValidation: !!process.env.CI || !!process.env.SKIP_ENV_VALIDATION, -}); diff --git a/apps/processor/src/index.ts b/apps/processor/src/index.ts deleted file mode 100644 index 6738c111..00000000 --- a/apps/processor/src/index.ts +++ /dev/null @@ -1,226 +0,0 @@ -import amqp from "amqplib"; -import { z } from "zod"; - -import { Prisma, prisma, type Participant } from "@sora/db"; -import { validateId } from "@sora/id-generator"; - -import { canVoteNow } from "./canVoteNow"; -import { env } from "./env"; -import { logger } from "./logger"; -import { trpc } from "./trpc"; - -const inputValidator = z.object({ - id: z.number().positive(), - qrId: z.string().refine(validateId), -}); - -const consumeMessagesFromQueue = async () => { - try { - logger.info("[DB] Connecting to Database..."); - - await prisma.$connect(); - - logger.info("[DB] Connected!"); - - if (!env.AMQP_URL) throw new Error("Diperlukan AQMP URL!"); - - logger.info(`[MQ] MQ AMQP: ${env.AMQP_URL}`); - logger.info(`[TRPC] TRPC URL: ${env.TRPC_URL}`); - - logger.info("[MQ] Connecting to RabbitMQ instance"); - - const connection = await amqp.connect(env.AMQP_URL); - const channel = await connection.createChannel(); - - const exchange = "vote"; - const queue = "vote_queue"; - const routingKey = "vote_rpc"; - - await channel.assertExchange(exchange, "direct", { durable: true }); - await channel.assertQueue(queue, { durable: true }); - await channel.bindQueue(queue, exchange, routingKey); - - logger.info("[MQ] Connected! Waiting for queue..."); - - channel.consume(queue, async (msg) => { - if (!msg) { - logger.warn("Consumer has been cancelled or channel has been closed."); - return; - } - - try { - const settings = await trpc.settings.getSettings.query(); - const inVotingCondition = canVoteNow(settings); - - const inputData = await inputValidator.safeParseAsync( - JSON.parse(msg.content.toString()), - ); - - if (!inputData.success) { - channel.sendToQueue( - msg.properties.replyTo, - Buffer.from( - JSON.stringify({ - success: false, - message: - "Data yang dijadikam permintaan tidak sesuai dengan standar yang ditetapkan!", - }), - ), - { correlationId: msg.properties.correlationId }, - ); - - channel.ack(msg); - logger.trace(`[MQ] Isn't a valid request data`); - - return; - } - - logger.info(`[MQ] New message! QR ID: ${inputData.data.qrId}`); - - if (!inVotingCondition) { - channel.sendToQueue( - msg.properties.replyTo, - Buffer.from( - JSON.stringify({ - success: false, - message: - "Tidak bisa memilih kandidat jika bukan dalam kondisi pemilihan!", - }), - ), - { correlationId: msg.properties.correlationId }, - ); - - channel.ack(msg); - logger.trace( - `[MQ] Isn't a valid time yet for voting. QR ID: ${inputData.data.qrId}`, - ); - - return; - } - - await prisma.$transaction( - async (tx) => { - const _participant = await tx.$queryRaw< - Participant[] - >`SELECT * FROM Participant WHERE qrId = ${inputData.data.qrId} FOR UPDATE`; - const participant = _participant[0]; - - if (!participant) { - channel.sendToQueue( - msg.properties.replyTo, - Buffer.from(JSON.stringify({ error: "Gak ada" })), - { correlationId: msg.properties.correlationId }, - ); - - channel.ack(msg); - logger.trace( - `[MQ] Participant isn't exist. QR ID: ${inputData.data.qrId}`, - ); - - return; - } - - if (participant.alreadyChoosing) { - channel.sendToQueue( - msg.properties.replyTo, - Buffer.from(JSON.stringify({ error: "Kamu sudah memilih!" })), - { correlationId: msg.properties.correlationId }, - ); - - channel.ack(msg); - logger.trace( - `[MQ] Participant already chosen someone. QR ID: ${inputData.data.qrId}`, - ); - - return; - } - - if (!participant.alreadyAttended) { - channel.sendToQueue( - msg.properties.replyTo, - Buffer.from(JSON.stringify({ error: "Kamu belum absen!" })), - { correlationId: msg.properties.correlationId }, - ); - - channel.ack(msg); - logger.trace( - `[MQ] Participant isn't attended yet. QR ID: ${inputData.data.qrId}`, - ); - - return; - } - - const _candidate = await tx.$queryRaw< - Participant[] - >`SELECT * FROM Candidate WHERE id = ${inputData.data.id} FOR UPDATE`; - const candidate = _candidate[0]; - - if (!candidate) { - channel.sendToQueue( - msg.properties.replyTo, - Buffer.from( - JSON.stringify({ error: "Kandidat yang dipilih tidak ada!" }), - ), - { correlationId: msg.properties.correlationId }, - ); - - channel.ack(msg); - logger.trace( - `[MQ] Candidate isn't exist. QR ID: ${inputData.data.qrId}`, - ); - - return; - } - - await tx.candidate.update({ - where: { id: inputData.data.id }, - data: { - counter: { - increment: 1, - }, - }, - }); - - await tx.participant.update({ - where: { qrId: inputData.data.qrId }, - data: { - alreadyChoosing: true, - choosingAt: new Date(), - }, - }); - - logger.info(`[MQ] Upvote! QR ID: ${inputData.data.qrId}`); - - channel.sendToQueue( - msg.properties.replyTo, - Buffer.from(JSON.stringify({ success: true })), - { correlationId: msg.properties.correlationId }, - ); - - channel.ack(msg); - - return; - }, - { - maxWait: 5000, - timeout: 10000, - isolationLevel: Prisma.TransactionIsolationLevel.Serializable, - }, - ); - } catch (error) { - logger.error(error); - - channel.sendToQueue( - msg.properties.replyTo, - Buffer.from(JSON.stringify({ error: "Internal Server Error" })), - { correlationId: msg.properties.correlationId }, - ); - - channel.ack(msg); - } - }); - } catch (error) { - logger.error(error); - } -}; -consumeMessagesFromQueue(); diff --git a/apps/processor/src/logger.ts b/apps/processor/src/logger.ts deleted file mode 100644 index 427c2334..00000000 --- a/apps/processor/src/logger.ts +++ /dev/null @@ -1,25 +0,0 @@ -import path from "path"; -import pino from "pino"; - -export const logger = pino({ - transport: { - targets: [ - { - target: "pino-pretty", - level: "debug", - options: { - colorize: true, - ignore: "pid,hostname", - translateTime: "SYS:standard", - }, - }, - { - target: "pino/file", - level: "debug", - options: { - destination: path.join(__dirname, "..", "processor.log"), - }, - }, - ], - }, -}); diff --git a/apps/processor/src/trpc.ts b/apps/processor/src/trpc.ts deleted file mode 100644 index 8d5a5343..00000000 --- a/apps/processor/src/trpc.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createTRPCProxyClient, httpLink } from "@trpc/client"; -import { type inferRouterOutputs } from "@trpc/server"; -import superjson from "superjson"; - -import type { AppRouter } from "@sora/api"; - -import { env } from "./env"; - -export const trpc = createTRPCProxyClient({ - transformer: superjson, - links: [ - httpLink({ - url: env.TRPC_URL, - }), - ], -}); - -export type RouterOutputs = inferRouterOutputs; diff --git a/apps/processor/tsconfig.json b/apps/processor/tsconfig.json deleted file mode 100644 index 42309b45..00000000 --- a/apps/processor/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "ts-node": { - "compilerOptions": { - "module": "commonjs" - } - }, - "include": ["src", "*.ts", "env.ts"] -} diff --git a/apps/processor/tsup.config.ts b/apps/processor/tsup.config.ts deleted file mode 100644 index 9cd2d4c9..00000000 --- a/apps/processor/tsup.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - clean: true, - entry: ["src/index.ts"], - noExternal: ["@sora/db", "@sora/id-generator"], - format: ["cjs"], -}); diff --git a/apps/web/.gitignore b/apps/web/.gitignore deleted file mode 100644 index a7a3ee33..00000000 --- a/apps/web/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -node_modules/ -/test-results/ -/playwright-report/ -/playwright/.cache/ - -e2e/.auth \ No newline at end of file diff --git a/apps/web/e2e/auth.setup.ts b/apps/web/e2e/auth.setup.ts deleted file mode 100644 index ffec67a8..00000000 --- a/apps/web/e2e/auth.setup.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { expect, test as setup } from "@playwright/test"; - -import { prisma } from "@sora/db"; - -const authFile = "e2e/.auth/storageState.json"; - -setup("authenticate", async ({ page }) => { - await prisma.$connect(); - - const userIsAvail = await prisma.user.findUnique({ - where: { email: "test123@mail.com" }, - }); - - if (userIsAvail) - await prisma.user.delete({ - where: { email: "test123@mail.com" }, - }); - - await prisma.$disconnect(); - - await page.goto("/register"); - - await expect(page.locator("h2")).toContainText("Register Administrator"); - await expect(page).toHaveURL("/register"); - - await page.locator('input[type="text"]').first().type("test123@mail.com"); - await page.locator('input[type="text"]').nth(1).type("User Test"); - - await page.locator('input[type="password"]').first().type("123456"); - await page.locator('input[type="password"]').nth(1).type("123456"); - - await page.getByRole("button", { name: "Register" }).click(); - - await expect(page.getByRole("button", { name: "Register" })).toHaveAttribute( - "disabled", - "", - ); - - await page.waitForURL("/login"); - - await expect(page.locator("h2")).toContainText("Login Administrator"); - await expect(page).toHaveURL("/login"); - - await page.locator('input[type="text"]').type("test123@mail.com"); - await page.locator('input[type="password"]').type("123456"); - - await page.getByRole("button", { name: "Login" }).click(); - - await expect(page.getByRole("button", { name: "Login" })).toBeDisabled(); - - await page.waitForURL("/"); - await expect(page).toHaveURL("/"); - - await page.locator('[id="__next"]').getByText("Dashboard Admin").waitFor(); - await expect( - page.locator('[id="__next"]').getByText("Dashboard Admin"), - ).toBeVisible(); - - await page.context().storageState({ path: authFile }); -}); diff --git a/apps/web/e2e/beforeLogin.test.ts b/apps/web/e2e/beforeLogin.test.ts deleted file mode 100644 index 15f6d5e2..00000000 --- a/apps/web/e2e/beforeLogin.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { expect, test, type Page } from "@playwright/test"; - -test.describe("There will be a redirection", () => { - const PROTECTED_PAGE = [ - "/", - "/kandidat", - "/kandidat/tambah", - "/peserta", - "/peserta/tambah", - "/peserta/csv", - "/peserta/pdf", - "/peserta/qr", - "/statistik", - "/pengaturan", - ]; - - for (const pageUrl of PROTECTED_PAGE) { - test(`Redirected from "${pageUrl}" to "/login" page`, async ({ page }) => { - await page.goto(pageUrl); - - const heading = page.locator("h2"); - - await heading.waitFor(); - - await expect(page.locator("h2")).toContainText("Login Administrator"); - - expect(page.url().includes("/login")).toBe(true); - }); - } -}); - -test.describe("Navigate from login to register and vice versa", () => { - test("Navigate from login page to registration page", async ({ page }) => { - await page.goto("/login"); - - await page.getByRole("link", { name: "Daftar" }).click(); - - const heading = page.locator("h2"); - - await heading.waitFor(); - - await expect(page.locator("h2")).toContainText("Register Administrator"); - await expect(page).toHaveURL("/register"); - }); - - test("Navigate from registration page to login page", async ({ page }) => { - await page.goto("/register"); - - await page.getByRole("link", { name: "Login" }).click(); - - const heading = page.locator("h2"); - - await heading.waitFor(); - - await expect(page.locator("h2")).toContainText("Login Administrator"); - await expect(page).toHaveURL("/login"); - }); -}); - -// For login and registration page -const invalidEmailInputs = ["dsdsasd", "test@", "eee@mail", "aaa.com"]; - -test.describe("Check login page form validation", () => { - const goToLogin = async (page: Page) => { - await page.goto("/login"); - - await expect(page.locator("h2")).toContainText("Login Administrator"); - await expect(page).toHaveURL("/login"); - }; - - test("All input box should complain", async ({ page }) => { - await goToLogin(page); - - await page.getByRole("button", { name: "Login" }).click(); - - await expect( - page.locator("div.chakra-form__error-message").first(), - ).toHaveText("Bidang email harus di isi!"); - await expect( - page.locator("div.chakra-form__error-message").nth(1), - ).toHaveText("Kata sandi harus di isi!"); - }); - - for (const input of invalidEmailInputs) { - test(`Email input isn't a valid email, input: ${input}`, async ({ - page, - }) => { - await goToLogin(page); - - await page.locator('input[type="text"]').type(input); - - await page.getByRole("button", { name: "Login" }).click(); - - await expect( - page.locator("div.chakra-form__error-message").first(), - ).toHaveText("Bidang email harus berupa email yang valid!"); - }); - } - - test("Password input at least 6 characters long", async ({ page }) => { - await goToLogin(page); - - await page.locator('input[type="password"]').type("abcde"); - - await page.getByRole("button", { name: "Login" }).click(); - - await expect( - page.locator("div.chakra-form__error-message").nth(1), - ).toHaveText("Kata sandi memiliki panjang setidaknya 6 karakter!"); - }); - - test("All input box wouldn't complain", async ({ page }) => { - await goToLogin(page); - - await page.locator('input[type="text"]').type("test123@mail.com"); - await page.locator('input[type="password"]').type("123456"); - - await page.getByRole("button", { name: "Login" }).click(); - - await expect(page.getByRole("button", { name: "Login" })).toHaveAttribute( - "disabled", - "", - ); - await expect(page.getByRole("button", { name: "Login" })).toHaveAttribute( - "data-loading", - "", - ); - }); -}); - -test.describe("Check registration page form validation", () => { - const goToRegistration = async (page: Page) => { - await page.goto("/register"); - - await expect(page.locator("h2")).toContainText("Register Administrator"); - await expect(page).toHaveURL("/register"); - }; - - test("All input box should complain", async ({ page }) => { - await goToRegistration(page); - - await page.getByRole("button", { name: "Register" }).click(); - - await expect( - page.locator("div.chakra-form__error-message").first(), - ).toHaveText("Bidang email harus di isi!"); - await expect( - page.locator("div.chakra-form__error-message").nth(1), - ).toHaveText("Bidang nama harus di isi!"); - await expect( - page.locator("div.chakra-form__error-message").nth(2), - ).toHaveText("Kata sandi harus di isi!"); - await expect( - page.locator("div.chakra-form__error-message").nth(3), - ).toHaveText("Konfirmasi kata sandi diperlukan setidaknya 6 karakter!"); - }); - - for (const input of invalidEmailInputs) { - test(`Email input isn't a valid email, input: ${input}`, async ({ - page, - }) => { - await goToRegistration(page); - - await page.locator('input[type="text"]').first().type(input); - - await page.getByRole("button", { name: "Register" }).click(); - - await expect( - page.locator("div.chakra-form__error-message").first(), - ).toHaveText("Bidang email harus berupa email yang valid!"); - }); - } -}); diff --git a/apps/web/e2e/fixtures/contoh-file-csv.csv b/apps/web/e2e/fixtures/contoh-file-csv.csv deleted file mode 100644 index 5d51a022..00000000 --- a/apps/web/e2e/fixtures/contoh-file-csv.csv +++ /dev/null @@ -1,4 +0,0 @@ -Nama, Bagian Dari -M. Fiqri Haikal,XII-IPA-5 -M. Rifqi Muflih,XII-BHS -Zain Arsi,XII-IPA-5 \ No newline at end of file diff --git a/apps/web/e2e/main/candidate.spec.ts b/apps/web/e2e/main/candidate.spec.ts deleted file mode 100644 index 20c810a5..00000000 --- a/apps/web/e2e/main/candidate.spec.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { expect, test, type Page } from "@playwright/test"; - -import { prisma } from "@sora/db"; - -test.describe.configure({ mode: "serial" }); - -const goToCandidatePage = async (page: Page) => { - await page.goto("/kandidat"); - - await page - .getByRole("button", { name: "Tambah Kandidat Baru", disabled: false }) - .waitFor(); - await page.getByRole("paragraph").filter({ hasText: "Kandidat" }).waitFor(); - - await expect( - page.getByRole("button", { name: "Tambah Kandidat Baru", disabled: false }), - ).toBeVisible(); - await expect( - page.getByRole("paragraph").filter({ hasText: "Kandidat" }), - ).toBeVisible(); -}; - -test.describe("Add new candidate page testing", () => { - const goToCandidateAddPage = async (page: Page) => { - await goToCandidatePage(page); - - await page - .getByRole("button", { - name: "Tambah Kandidat Baru", - }) - .click(); - - await page.waitForURL("/kandidat/tambah"); - - await page - .getByRole("paragraph") - .filter({ hasText: "Tambah Kandidat Baru" }) - .waitFor(); - - await expect( - page.getByRole("paragraph").filter({ hasText: "Tambah Kandidat Baru" }), - ).toBeVisible(); - }; - - test("Check form validation", async ({ page }) => { - await goToCandidateAddPage(page); - - await expect(page.getByRole("button", { name: "Tambah" })).toBeVisible(); - - await page.getByRole("button", { name: "Tambah" }).click(); - - await expect(page.getByText("Diperlukan nama kandidat!")).toBeVisible(); - await expect(page.getByText("Diperlukan gambar kandidat!")).toBeVisible(); - }); - - test("Invalid file format, can only upload .jpg, .jpeg, .png and .webp", async ({ - page, - }) => { - await goToCandidateAddPage(page); - - await expect(page.getByRole("button", { name: "Tambah" })).toBeVisible(); - - await page.setInputFiles('input[type="file"]', "e2e/beforeLogin.test.ts"); - - await page.getByRole("button", { name: "Tambah" }).click(); - - await expect( - page.getByText( - "Hanya format gambar .jpg, .jpeg, .png dan .webp yang diterima!", - ), - ).toBeVisible(); - }); - - test("Create new candidate", async ({ page }) => { - await prisma.$connect(); - - const candidateIsAvail = await prisma.candidate.findUnique({ - where: { name: "Entonk" }, - }); - - if (candidateIsAvail) - await prisma.candidate.delete({ - where: { name: "Entonk" }, - }); - - await prisma.$disconnect(); - - await goToCandidateAddPage(page); - - await page.getByPlaceholder("Masukan Nama Kandidat").type("Entonk"); - await page.setInputFiles( - 'input[type="file"]', - "../../assets/samples/Kandidat Nama Orang/1_Entonk.png", - ); - - await page.getByRole("button", { name: "Tambah" }).click(); - - await expect(page.getByRole("button", { name: "Tambah" })).toBeDisabled(); - - await page - .getByRole("button", { name: "Tambah", disabled: false }) - .waitFor(); - - await page.waitForURL("/kandidat"); - - await page.getByRole("button", { name: "Tambah Kandidat Baru" }).waitFor(); - - await expect( - page.getByRole("button", { name: "Tambah Kandidat Baru" }), - ).toBeVisible(); - - await page.getByText("Entonk").waitFor(); - - await expect(page.getByText("Entonk")).toBeVisible(); - await expect(page.getByText("0 Orang")).toBeVisible(); - await expect( - page.getByRole("img", { name: "Gambar dari kandidat Entonk." }), - ).toBeVisible(); - await expect(page.getByRole("button", { name: "Edit" })).toBeVisible(); - await expect(page.getByRole("button", { name: "Hapus" })).toBeVisible(); - }); -}); - -test.describe("Edit page functionality testing", () => { - test("Candidate name can't be empty", async ({ page }) => { - await goToCandidatePage(page); - - // Waiting for Entonk candidate - await page.getByText("Entonk").waitFor(); - - // Ensure entire information is visible - await expect(page.getByText("Entonk")).toBeVisible(); - await expect(page.getByText("0 Orang")).toBeVisible(); - await expect( - page.getByRole("img", { name: "Gambar dari kandidat Entonk." }), - ).toBeVisible(); - await expect(page.getByRole("button", { name: "Edit" })).toBeVisible(); - await expect(page.getByRole("button", { name: "Hapus" })).toBeVisible(); - - // Real test start from here - await page.getByRole("button", { name: "Edit" }).click(); - - await page.getByPlaceholder("Masukan Nama Kandidat").waitFor(); - await page.locator('input[type="file"]').waitFor(); - - await expect(page.getByPlaceholder("Masukan Nama Kandidat")).toHaveValue( - "Entonk", - ); - - await page.getByPlaceholder("Masukan Nama Kandidat").clear(); - - await page.getByRole("button", { name: "Edit" }).click(); - - await page.getByText("Diperlukan nama kandidat!").waitFor(); - - await expect(page.getByText("Diperlukan nama kandidat!")).toBeVisible(); - }); - - test("Update candidate name to Ujang", async ({ page }) => { - await goToCandidatePage(page); - - // Waiting for Entonk candidate - await page.getByText("Entonk").waitFor(); - - // Ensure entire information is visible - await expect(page.getByText("Entonk")).toBeVisible(); - await expect(page.getByText("0 Orang")).toBeVisible(); - await expect( - page.getByRole("img", { name: "Gambar dari kandidat Entonk." }), - ).toBeVisible(); - await expect(page.getByRole("button", { name: "Edit" })).toBeVisible(); - await expect(page.getByRole("button", { name: "Hapus" })).toBeVisible(); - - // Real test start from here, changes Entonk -> Ujang - await page.getByRole("button", { name: "Edit" }).click(); - - await page.getByPlaceholder("Masukan Nama Kandidat").waitFor(); - await page.locator('input[type="file"]').waitFor(); - - await expect(page.getByPlaceholder("Masukan Nama Kandidat")).toHaveValue( - "Entonk", - ); - - await page.getByPlaceholder("Masukan Nama Kandidat").clear(); - await page.getByPlaceholder("Masukan Nama Kandidat").type("Ujang"); - - await page.getByRole("button", { name: "Edit" }).click(); - - await page.waitForURL("/kandidat"); - - await page.getByRole("button", { name: "Tambah Kandidat Baru" }).waitFor(); - - await expect( - page.getByRole("button", { name: "Tambah Kandidat Baru" }), - ).toBeVisible(); - - await page.getByText("Ujang").waitFor(); - - await expect(page.getByText("Ujang")).toBeVisible(); - await expect(page.getByText("0 Orang")).toBeVisible(); - await expect( - page.getByRole("img", { name: "Gambar dari kandidat Ujang." }), - ).toBeVisible(); - await expect(page.getByRole("button", { name: "Edit" })).toBeVisible(); - await expect(page.getByRole("button", { name: "Hapus" })).toBeVisible(); - }); -}); - -test("Delete candidate from available list", async ({ page }) => { - await goToCandidatePage(page); - - // Waiting for Ujang candidate - await page.getByText("Ujang").waitFor(); - - // Ensure entire information is visible - await expect(page.getByText("Ujang")).toBeVisible(); - await expect(page.getByText("0 Orang")).toBeVisible(); - await expect( - page.getByRole("img", { name: "Gambar dari kandidat Ujang." }), - ).toBeVisible(); - await expect(page.getByRole("button", { name: "Edit" })).toBeVisible(); - await expect(page.getByRole("button", { name: "Hapus" })).toBeVisible(); - - await page.getByRole("button", { name: "Hapus" }).click(); - - await page.getByText("Hapus Kandidat").waitFor(); - - await expect(page.getByText("Hapus Kandidat")).toBeVisible(); - await expect(page.getByRole("button", { name: "Batal" })).toBeVisible(); - await expect(page.getByRole("button", { name: "Hapus" })).toBeVisible(); - - await page.getByRole("button", { name: "Hapus" }).click(); - - await page - .getByText( - "Tidak ada kandidat yang tersedia, silahkan tambahkan kandidat terlebih dahulu.", - ) - .waitFor(); - - await expect( - page.getByText( - "Tidak ada kandidat yang tersedia, silahkan tambahkan kandidat terlebih dahulu.", - ), - ).toBeVisible(); -}); diff --git a/apps/web/e2e/main/participant.spec.ts b/apps/web/e2e/main/participant.spec.ts deleted file mode 100644 index 23476a12..00000000 --- a/apps/web/e2e/main/participant.spec.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { expect, test, type Page } from "@playwright/test"; - -import { prisma } from "@sora/db"; - -test.describe.configure({ mode: "serial" }); - -const goToParticipantPage = async (page: Page) => { - await page.goto("/peserta"); - - await page.getByText("Peserta Pemilih").waitFor(); - - await expect( - page.getByRole("button", { name: "Tambah Peserta Baru" }), - ).toBeVisible(); - await expect( - page.getByRole("button", { name: "Upload File CSV" }), - ).toBeVisible(); - await expect(page.getByRole("button", { name: "Cetak PDF" })).toBeVisible(); - await expect( - page.getByRole("button", { name: "Buat QR Dadakan" }), - ).toBeVisible(); - await expect(page.getByRole("button", { name: "Export JSON" })).toBeVisible(); -}; - -test.describe("Test create single participant", () => { - const goToCreateParticipantPage = async (page: Page) => { - const availParticipant = await prisma.participant.count(); - - if (availParticipant > 0) await prisma.participant.deleteMany(); - - await goToParticipantPage(page); - - await expect( - page.getByText( - "Tidak ada data peserta, Silahkan tambah peserta baru dengan tombol di atas.", - ), - ).toBeVisible(); - - await page.getByRole("link", { name: "Tambah Peserta Baru" }).click(); - - await page.getByText("Nama Peserta").waitFor(); - - await expect(page.getByText("Nama Peserta")).toBeVisible(); - await expect(page).toHaveURL("/peserta/tambah"); - }; - - test("All form inputs will throw an error empty value", async ({ page }) => { - await goToCreateParticipantPage(page); - - await page.getByRole("button", { name: "Tambah" }).click(); - - await expect(page.getByText("Diperlukan nama peserta!")).toBeVisible(); - await expect( - page.getByText("Diperlukan bagian darimana peserta ini!"), - ).toBeVisible(); - }); - - test("Can't type weird name and part of fields", async ({ page }) => { - await goToCreateParticipantPage(page); - - await page.getByPlaceholder("Masukan Nama Peserta").type("!@#$%^&*()_+"); - await page - .getByPlaceholder("Masukan Peserta Bagian Dari") - .type("!@#$%^&*()_+"); - - await page.getByRole("button", { name: "Tambah" }).click(); - - await expect( - page.getByText( - "Hanya diperbolehkan menulis alfabet, angka, koma, petik satu, dan titik!", - ), - ).toBeVisible(); - await expect( - page.getByText( - "Hanya diperbolehkan menulis alfabet, angka, dan garis bawah!", - ), - ).toBeVisible(); - }); - - test("Create new single participant", async ({ page }) => { - await goToCreateParticipantPage(page); - - await page.getByPlaceholder("Masukan Nama Peserta").type("Fiqri"); - await page - .getByPlaceholder("Masukan Peserta Bagian Dari") - .type("XII-IPA-4"); - - await page.getByRole("button", { name: "Tambah" }).click(); - - await page.waitForURL("/peserta"); - - await expect( - page.getByRole("button", { name: "Tambah Peserta Baru" }), - ).toBeVisible(); - await expect( - page.getByRole("button", { name: "Upload File CSV" }), - ).toBeVisible(); - await expect(page.getByRole("button", { name: "Cetak PDF" })).toBeVisible(); - await expect( - page.getByRole("button", { name: "Buat QR Dadakan" }), - ).toBeVisible(); - await expect( - page.getByRole("button", { name: "Export JSON" }), - ).toBeVisible(); - - await expect(page.getByText("Fiqri")).toBeVisible(); - await expect(page.getByText("XII-IPA-4")).toBeVisible(); - }); -}); - -test("Edit individual participant", async ({ page }) => { - await goToParticipantPage(page); - - await expect(page.getByRole("button", { name: "Edit" })).toBeVisible(); - - await page.getByRole("button", { name: "Edit" }).click(); - - await page.getByText("Nama Peserta").waitFor(); - - await expect(page.getByText("Nama Peserta")).toBeVisible(); - await expect(page).toHaveURL(/\/peserta\/edit\/*/); - - await page.getByPlaceholder("Masukan Nama Peserta").clear(); - await page.getByPlaceholder("Masukan Peserta Bagian Dari").clear(); - - await page.getByPlaceholder("Masukan Nama Peserta").type("Fiqri H."); - await page.getByPlaceholder("Masukan Peserta Bagian Dari").type("XII-IPA-5"); - - await page.getByRole("button", { name: "Ubah" }).click(); - - await page.waitForURL("/peserta"); - - await expect( - page.getByRole("button", { name: "Tambah Peserta Baru" }), - ).toBeVisible(); - await expect( - page.getByRole("button", { name: "Upload File CSV" }), - ).toBeVisible(); - await expect(page.getByRole("button", { name: "Cetak PDF" })).toBeVisible(); - await expect( - page.getByRole("button", { name: "Buat QR Dadakan" }), - ).toBeVisible(); - await expect(page.getByRole("button", { name: "Export JSON" })).toBeVisible(); - - await expect(page.getByText("Fiqri H.")).toBeVisible(); - await expect(page.getByText("XII-IPA-5")).toBeVisible(); -}); - -test("Delete single participant", async ({ page }) => { - await goToParticipantPage(page); - - await expect(page.getByRole("button", { name: "Hapus" })).toBeVisible(); - - await page.getByRole("button", { name: "Hapus" }).click(); - - await expect(page.getByText("Hapus Peserta")).toBeVisible(); - - await page.getByRole("button", { name: "Hapus" }).click(); - - await expect( - page.getByText( - "Tidak ada data peserta, Silahkan tambah peserta baru dengan tombol di atas.", - ), - ).toBeVisible(); -}); - -test("Upload csv file", async ({ page }) => { - await goToParticipantPage(page); - - await page.getByRole("button", { name: "Upload File CSV" }).click(); - - await page.getByText("Kembali").waitFor(); - - await expect(page.getByText("Kembali")).toBeVisible(); - await expect(page).toHaveURL("/peserta/csv"); - - await page - .getByPlaceholder("Masukan File CSV") - .setInputFiles("e2e/fixtures/contoh-file-csv.csv"); - - await page.getByRole("button", { name: "Tambah" }).click(); - - await page.waitForURL("/peserta"); - - await expect( - page.getByRole("button", { name: "Tambah Peserta Baru" }), - ).toBeVisible(); - await expect( - page.getByRole("button", { name: "Upload File CSV" }), - ).toBeVisible(); - await expect(page.getByRole("button", { name: "Cetak PDF" })).toBeVisible(); - await expect( - page.getByRole("button", { name: "Buat QR Dadakan" }), - ).toBeVisible(); - await expect(page.getByRole("button", { name: "Export JSON" })).toBeVisible(); - - await expect(page.getByText("M. Fiqri Haikal")).toBeVisible(); - await expect(page.getByText("M. Rifqi Muflih")).toBeVisible(); - await expect(page.getByText("Zain Arsi")).toBeVisible(); -}); - -test.describe("PDF page test", () => { - const goToPDFPage = async (page: Page) => { - await goToParticipantPage(page); - - await page.getByRole("button", { name: "Cetak PDF" }).click(); - - await expect( - page.getByRole("heading", { name: "Peserta Pemilihan" }), - ).toBeVisible(); - await expect(page).toHaveURL("/peserta/pdf"); - }; - - test("It shows corresponding participant by selected category", async ({ - page, - }) => { - await goToPDFPage(page); - - await page.getByRole("combobox").selectOption("XII-IPA-5"); - - await expect(page.getByText("M. Fiqri Haikal")).toBeVisible(); - await expect(page.getByText("Zain Arsi")).toBeVisible(); - - await page.getByRole("combobox").selectOption("XII-BHS"); - - await expect(page.getByText("M. Rifqi Muflih")).toBeVisible(); - }); - - test("Will include QR Code web if there's an URL", async ({ page }) => { - await goToPDFPage(page); - - await page.getByRole("combobox").selectOption("XII-IPA-5"); - - await page - .getByPlaceholder("Web QR Code | Misal https://example.com") - .type("https://sora.rmecha.my.id"); - - await expect(page.getByText("Klik Disini").first()).toHaveAttribute( - "href", - /https:\/\/sora.rmecha.my.id\/qr\/*/, - ); - await expect(page.getByText("Klik Disini").nth(1)).toHaveAttribute( - "href", - /https:\/\/sora.rmecha.my.id\/qr\/*/, - ); - - await page.getByRole("combobox").selectOption("XII-BHS"); - - await expect(page.getByText("Klik Disini").first()).toHaveAttribute( - "href", - /https:\/\/sora.rmecha.my.id\/qr\/*/, - ); - }); -}); diff --git a/apps/web/e2e/main/setttings.spec.ts b/apps/web/e2e/main/setttings.spec.ts deleted file mode 100644 index 7bee55ca..00000000 --- a/apps/web/e2e/main/setttings.spec.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { expect, test, type Page } from "@playwright/test"; - -test.describe.configure({ mode: "serial" }); - -const goToSettingsPage = async (page: Page) => { - await page.goto("/pengaturan"); - - await page.getByRole("paragraph").filter({ hasText: "Pengaturan" }).waitFor(); - - // Sudah Bisa Memilih - await expect( - page - .getByRole("group") - .filter({ hasText: "Sudah Bisa Memilih" }) - .locator("span") - .first(), - ).toBeVisible(); - - // Sudah Bisa Absen - await expect(page.getByPlaceholder("Tetapkan waktu mulai")).toBeVisible(); - - await expect(page.getByPlaceholder("Tetapkan waktu selesai")).toBeVisible(); -}; - -test.describe("Time behaviour settings test", () => { - const ensureNotChecked = async (page: Page) => { - await goToSettingsPage(page); - - await expect(page.getByPlaceholder("Tetapkan waktu mulai")).toHaveValue(""); - await expect(page.getByPlaceholder("Tetapkan waktu selesai")).toHaveValue( - "", - ); - - const saveBtn = page.getByRole("button", { name: "Simpan" }).nth(1); - - return { saveBtn }; - }; - - test("All time settings would complain because it's empty", async ({ - page, - }) => { - const { saveBtn } = await ensureNotChecked(page); - - await saveBtn.click(); - - await page.getByText("Diperlukan kapan waktu mulai pemilihan!").waitFor(); - await page.getByText("Diperlukan kapan waktu selesai pemilihan!").waitFor(); - - await expect( - page.getByText("Diperlukan kapan waktu mulai pemilihan!"), - ).toBeVisible(); - await expect( - page.getByText("Diperlukan kapan waktu selesai pemilihan!"), - ).toBeVisible(); - }); - - test("Set start time at 7:30 AM and ended at 12:00 PM", async ({ page }) => { - const { saveBtn } = await ensureNotChecked(page); - - await page.getByPlaceholder("Tetapkan waktu mulai").click(); - - await page.getByText("7:30 AM").waitFor(); - await page.getByText("7:30 AM").scrollIntoViewIfNeeded(); - - await expect(page.getByText("7:30 AM")).toBeVisible(); - - await page.getByText("7:30 AM").click(); - - // As long as a text - await expect(page.getByPlaceholder("Tetapkan waktu mulai")).toHaveValue( - /.*/, - ); - - await page.getByPlaceholder("Tetapkan waktu selesai").click(); - - await page.getByText("12:00 PM").waitFor(); - await page.getByText("12:00 PM").scrollIntoViewIfNeeded(); - - await expect(page.getByText("12:00 PM")).toBeVisible(); - - await page.getByText("12:00 PM").click(); - - await expect(page.getByPlaceholder("Tetapkan waktu selesai")).toHaveValue( - /.*/, - ); - - await saveBtn.click(); - }); -}); - -test("You can turn on the entire behaviour settings", async ({ page }) => { - await goToSettingsPage(page); - - await page - .getByRole("group") - .filter({ hasText: "Sudah Bisa Memilih" }) - .locator("span") - .first() - .click(); - - await page - .getByRole("group") - .filter({ hasText: "Sudah Bisa Absen" }) - .locator("span") - .first() - .click(); - - await page.getByRole("button", { name: "Simpan" }).first().click(); -}); diff --git a/apps/web/package.json b/apps/web/package.json deleted file mode 100644 index 6e2f69c9..00000000 --- a/apps/web/package.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "name": "@sora/web", - "version": "0.1.0", - "private": true, - "scripts": { - "e2e": "yarn with-env-test playwright test", - "e2e:ui": "yarn with-env-test playwright test --ui", - "e2e:report": "playwright show-report", - "build": "yarn with-env next build", - "clean": "git clean -xdf .next .turbo node_modules", - "dev": "yarn with-env next dev", - "lint": "dotenv -v SKIP_ENV_VALIDATION=1 next lint", - "lint:fix": "pnpm lint --fix", - "start": "yarn with-env next start", - "type-check": "tsc --noEmit", - "with-env": "dotenv -e ../../.env --", - "with-env-test": "dotenv -e ../../.env.test --" - }, - "dependencies": { - "@chakra-ui/react": "^2.8.1", - "@emotion/react": "^11.11.1", - "@emotion/styled": "^11.11.0", - "@hookform/resolvers": "^3.1.0", - "@sora/api": "^0.1.0", - "@sora/auth": "^0.1.0", - "@sora/db": "^0.1.0", - "@sora/id-generator": "^0.1.0", - "@sora/ui": "^0.1.0", - "@t3-oss/env-nextjs": "^0.3.1", - "@tanstack/react-query": "^4.29.5", - "@tanstack/react-table": "^8.9.1", - "@trpc/client": "^10.38.1", - "@trpc/next": "^10.38.1", - "@trpc/react-query": "^10.38.1", - "@trpc/server": "^10.38.1", - "bcrypt": "^5.1.0", - "cors": "^2.8.5", - "csv-parse": "^5.5.0", - "formidable": "^2.1.1", - "framer-motion": "^10.16.0", - "json-as-xlsx": "^2.5.4", - "lodash": "^4.17.21", - "luxon": "^3.4.3", - "mime-types": "^2.1.35", - "mv": "^2.1.1", - "next": "13.4.7", - "next-auth": "^4.22.1", - "next-connect": "^1.0.0", - "qrcode": "^1.5.3", - "react": "18.2.0", - "react-datepicker": "^4.12.0", - "react-dom": "18.2.0", - "react-hook-form": "^7.44.1", - "react-icons": "^4.8.0", - "recharts": "^2.6.2", - "superjson": "1.13.1", - "zod": "^3.22.2" - }, - "devDependencies": { - "@playwright/test": "^1.34.3", - "@sora/eslint-config": "^0.1.0", - "@types/bcrypt": "^5.0.0", - "@types/cors": "^2.8.13", - "@types/formidable": "^2.0.6", - "@types/lodash": "^4.14.195", - "@types/luxon": "^3.3.2", - "@types/mime-types": "^2.1.1", - "@types/mv": "^2.1.2", - "@types/node": "^20.2.5", - "@types/qrcode": "^1.5.0", - "@types/react": "^18.2.6", - "@types/react-datepicker": "^4.11.2", - "@types/react-dom": "^18.2.4", - "dotenv-cli": "^7.2.1", - "eslint": "^8.40.0", - "typescript": "^5.2.2" - } -} diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts deleted file mode 100644 index d09699b0..00000000 --- a/apps/web/playwright.config.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { defineConfig, devices } from "@playwright/test"; - -/** - * Read environment variables from file. - * https://github.com/motdotla/dotenv - */ -// require('dotenv').config(); - -/** - * See https://playwright.dev/docs/test-configuration. - */ -export default defineConfig({ - testDir: "./e2e", - /* Run tests in files in parallel */ - fullyParallel: true, - /* Fail the build on CI if you accidentally left test.only in the source code. */ - forbidOnly: !!process.env.CI, - /* Retry on CI only */ - retries: process.env.CI ? 2 : 0, - /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, - /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: "html", - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ - use: { - /* Base URL to use in actions like `await page.goto('/')`. */ - baseURL: "http://localhost:9999", - - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: "on-first-retry", - }, - - /* Configure projects for major browsers */ - projects: [ - { - name: "setup", - testMatch: /.*\.setup\.ts/, - }, - { - name: "chromium", - grep: /.*spec.ts/, - grepInvert: /.*test.ts/, - use: { - ...devices["Desktop Chrome"], - storageState: "e2e/.auth/storageState.json", - }, - dependencies: ["setup"], - }, - - // { - // name: "firefox", - // grep: /.*spec.ts/, - // grepInvert: /.*test.ts/, - // use: { - // ...devices["Desktop Firefox"], - // storageState: "e2e/.auth/storageState.json", - // }, - // dependencies: ["setup"], - // }, - // { - // name: "webkit", - // grep: /.*spec.ts/, - // grepInvert: /.*test.ts/, - // use: { - // ...devices["Desktop Safari"], - // storageState: "e2e/.auth/storageState.json", - // }, - // dependencies: ["setup"], - // }, - - // Before login test - { - name: "chromium - before login", - testMatch: "beforeLogin.test.ts", - use: { - ...devices["Desktop Chrome"], - }, - }, - { - name: "firefox - before login", - testMatch: "beforeLogin.test.ts", - use: { - ...devices["Desktop Firefox"], - }, - }, - { - name: "webkit - before login", - testMatch: "beforeLogin.test.ts", - use: { - ...devices["Desktop Safari"], - }, - }, - - /* Test against mobile viewports. */ - // { - // name: 'Mobile Chrome', - // use: { ...devices['Pixel 5'] }, - // }, - // { - // name: 'Mobile Safari', - // use: { ...devices['iPhone 12'] }, - // }, - - /* Test against branded browsers. */ - // { - // name: 'Microsoft Edge', - // use: { ...devices['Desktop Edge'], channel: 'msedge' }, - // }, - // { - // name: 'Google Chrome', - // use: { ..devices['Desktop Chrome'], channel: 'chrome' }, - // }, - ], - - /* Run your local dev server before starting the tests */ - webServer: { - command: "yarn start -p 9999", - url: "http://127.0.0.1:9999", - reuseExistingServer: !process.env.CI, - }, -}); diff --git a/apps/web/public/favicon.ico b/apps/web/public/favicon.ico deleted file mode 100644 index 9b68748791a23718e0d9d75c372437b33ceac3e3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4286 zcmeHJ-AmJ96#i|~^7~`z%xN`UAAVj`8l-}olBf&PZYn=lqWBdU2)T?*(n&VW$PW-| z=0cpgE4#>xO@#?YdbPmIT`KrTJiX@~8`Hs@bP?3=H_pa6@6LJ7^StMMH$jlFmr5nj zyReiX2nsd%OpM3wA z*Y}T+hU|_-VNnr`qoat6k2{hRs>Tsfkw}myLh8pMJ~08&G10syiYF$?aZZ_rv~(pR zqoaJi5`LCijosZ{l$4h8enQ3d_J8fhby%0JyguhC5zzaz7q5nfJUC2?euDwu*VbU3 zpNB@P`|~)d7s8|b&CN~J-mBwviBnZ|6}Gmvph!*S zHD3-6GHf%F6)BuQ`=R6gel&l=>bF(6TrM0O9KiC$g7l1Z(E3T(-gUxzJv@xr*;$@P zzMSd3zOfFSPKTJ-ShVQ%T%-Jg0-wgjUwui9oV;9=X*F>Da-mt@g0qnkAh=^=RsSpg=5`;7268h+S_3?8sTs_@T|Yz(<95HnJBNQ!0pOPRM*sC z`^Pp0UJUs9^YRP!NjlvsF2>y49Nca!^1-?E-s>>sgYw5f7)Nl z%eZhJAEu_TV6ouy!UC48R`m7tfzD$v%z!m1J4dYE2;(?CGtG0!PfAI~2AhkTJ38N7 z2oldZCEe)E=pX6P^P~seJ>A?oIy-PNQ^o5A-~{>qLwjDP(ZK0+B2B6EXv|`7#>O!D zellP`z4(Ub228x0!1%;CH`BNYg*S^r#wVUMJ2PzdNW=a8eWrgcI=i}f%x<^i=gtmN z6pDZzh#%!2w^Q+EI;yfVQQuIH`o;#_fA|2|IXNgUEkRRr6OZ3z{m?ll&Znsz@^@KX n(%)y~vwimYY)!&uB;L|{DI0OIV46yt3uRgy47FoDJ diff --git a/apps/web/public/sora.png b/apps/web/public/sora.png deleted file mode 100644 index f02274b550a0d90610e91a03583ac68a8f6d4fb1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 23975 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 diff --git a/apps/web/src/components/DatePicker/DatePicker.tsx b/apps/web/src/components/DatePicker/DatePicker.tsx deleted file mode 100644 index 119e748f..00000000 --- a/apps/web/src/components/DatePicker/DatePicker.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React, { type HTMLAttributes } from "react"; -import { useColorMode } from "@chakra-ui/react"; -import ReactDatePicker, { type ReactDatePickerProps } from "react-datepicker"; - -type Props = { - selectedDate: Date | null; - customStyles?: React.CSSProperties; -}; - -export const DatePicker = ({ - selectedDate, - onChange, - customStyles, - ...props -}: Props & ReactDatePickerProps & HTMLAttributes) => { - const isLight = useColorMode().colorMode === "light"; - - return ( -
- -
- ); -}; diff --git a/apps/web/src/components/DatePicker/chakra-support.css b/apps/web/src/components/DatePicker/chakra-support.css deleted file mode 100644 index 1949b057..00000000 --- a/apps/web/src/components/DatePicker/chakra-support.css +++ /dev/null @@ -1,213 +0,0 @@ -.light-theme { - --light-gray: var(--chakra-colors-gray-200); - --gray: var(--chakra-colors-gray-300); - --blue700: var(--chakra-colors-blue-600); - --blue600: var(--chakra-colors-blue-500); - --blue500: var(--chakra-colors-gray-400); - --blue400: var(--chakra-colors-gray-300); - --blue300: var(--chakra-colors-gray-200); - --blue200: var(--chakra-colors-gray-200); - --blue100: var(--chakra-colors-gray-100); - --monthBackground: var(--chakra-colors-white); - --text: var(--chakra-colors-black); - --negative-text: var(--chakra-colors-white); - --disabled-date-text: #00000059; - --disabled-time-text: #0000005c; -} -.dark-theme { - --light-gray: var(--chakra-colors-gray-600); - --gray: var(--chakra-colors-gray-500); - --blue700: var(--chakra-colors-blue-600); - --blue600: var(--chakra-colors-blue-300); - --blue500: var(--chakra-colors-gray-500); - --blue400: var(--chakra-colors-gray-600); - --blue300: var(--chakra-colors-gray-700); - --blue200: var(--chakra-colors-gray-600); - --blue100: var(--chakra-colors-gray-800); - --monthBackground: var(--chakra-colors-gray-700); - --text: var(--chakra-colors-gray-200); - --negative-text: var(--chakra-colors-black); - --disabled-date-text: #ffffff70; - --disabled-time-text: #ffffff30; -} - -.light-theme-original { - --light-gray: #cccccc; - --gray: #b3b3b3; - --blue700: #2a69ac; - --blue600: #3182ce; - --blue500: #a0aec0; - --blue400: #cbd5e0; - --blue300: #e2e8f0; - --blue200: #edf2f7; - --blue100: #f7fafc; -} -.react-datepicker { - min-width: 327px; - font-family: unset; - font-size: 0.9rem; - border-color: var(--light-gray); -} - -.react-datepicker-wrapper, -.react-datepicker__input-container { - display: block; -} - -.react-datepicker__input-container { - font-size: 1rem; - padding-left: 1rem; - padding-right: 1rem; - height: 2.5rem; - border-radius: 0.25rem; - border: 1px solid; - border-color: var(--light-gray); -} -.react-datapicker__input-text { - background-color: transparent; -} - -.react-datepicker__input-container:hover { - border-color: var(--gray); -} -.react-datepicker__input-container:focus-within { - z-index: 1; - border-color: var(--blue600); - box-shadow: 0 0 0 1px var(--blue600); -} - -.react-datepicker__input-container > input { - width: 100%; - height: 100%; - outline: 0; -} - -.react-datepicker__time-container { - position: absolute; - right: 0; -} - -.react-datepicker__navigation--previous, -.react-datepicker__navigation--next { - transform: translateY(5px); -} - -.react-datepicker__navigation--previous { - border-right-color: var(--blue400); -} - -.react-datepicker__navigation--previous:hover { - border-right-color: var(--blue500); -} - -.react-datepicker__navigation--next { - border-left-color: var(--blue400); -} - -.react-datepicker__navigation--next:hover { - border-left-color: var(--blue500); -} - -.react-datepicker__header { - background-color: var(--blue100); -} - -.react-datepicker__header, -.react-datepicker__time-container { - border-color: var(--blue300); -} - -.react-datepicker__current-month, -.react-datepicker-time__header, -.react-datepicker-year-header { - font-size: inherit; - font-weight: 600; - color: var(--text); -} - -.react-datepicker__month, -.react-datepicker__time-list-item { - background-color: var(--monthBackground); - margin: 0; - padding: 0.4rem; -} - -.react-datepicker__time-container - .react-datepicker__time - .react-datepicker__time-box - ul.react-datepicker__time-list - li.react-datepicker__time-list-item { - height: 32px !important; - padding: 5px 10px !important; - white-space: nowrap; - overflow: hidden; -} - -.react-datepicker__time-container - .react-datepicker__time - .react-datepicker__time-box - ul.react-datepicker__time-list - li.react-datepicker__time-list-item:hover { - background: var(--blue200); -} - -.react-datepicker__day, -.react-datepicker__time-list-item { - color: var(--text); -} - -.react-datepicker__day:hover, -.react-datepicker__time-list-item:hover { - background: var(--blue200); -} - -.react-datepicker__day-name, -.react-datepicker__time-list-item { - color: var(--text); -} - -.react-datepicker__day--selected, -.react-datepicker__day--in-selecting-range, -.react-datepicker__day--in-range, -.react-datepicker__month-text--selected, -.react-datepicker__month-text--in-selecting-range, -.react-datepicker__month-text--in-range, -.react-datepicker__time-container - .react-datepicker__time - .react-datepicker__time-box - ul.react-datepicker__time-list - li.react-datepicker__time-list-item--selected { - background: var(--blue600); - font-weight: normal; - color: var(--negative-text); -} - -.react-datepicker__time-container - .react-datepicker__time - .react-datepicker__time-box - ul.react-datepicker__time-list - li.react-datepicker__time-list-item--selected:hover { - background: var(--blue700); -} - -.react-datepicker__close-icon::after { - background-color: unset; - border-radius: unset; - font-size: 1.5rem; - font-weight: bold; - color: var(--light-gray); - height: 20px; - width: 20px; -} - -.react-datepicker__close-icon::after:hover { - color: var(--gray); -} - -.react-datepicker__day--disabled { - color: var(--disabled-date-text); -} - -.react-datepicker__time-list-item--disabled { - color: var(--disabled-date-text) !important; -} diff --git a/apps/web/src/components/DatePicker/index.tsx b/apps/web/src/components/DatePicker/index.tsx deleted file mode 100644 index cfd145ba..00000000 --- a/apps/web/src/components/DatePicker/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { DatePicker as default } from "./DatePicker"; diff --git a/apps/web/src/components/InputImageBox.tsx b/apps/web/src/components/InputImageBox.tsx deleted file mode 100644 index c70db0f4..00000000 --- a/apps/web/src/components/InputImageBox.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import { useEffect, useRef, type InputHTMLAttributes } from "react"; -import { - AspectRatio, - Box, - Heading, - Input, - Stack, - Text, - useToast, - type InputProps, -} from "@chakra-ui/react"; - -export type IIBType = { - imgFromInput: string | null; - count: number; - onImageDropped: (image: FileList) => void; -} & InputHTMLAttributes & - InputProps; - -export default function InputImageBox({ - onImageDropped, - imgFromInput, - onChange, - count, - ...props -}: IIBType) { - const toast = useToast(); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const drop = useRef(null!); - - useEffect(() => { - const extensions = ["image/jpg", "image/jpeg", "image/png"]; - const instance: HTMLDivElement = drop.current; - - const handleDrop = (e: DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - - const { files } = e.dataTransfer as { files: FileList }; - - if (count < files.length) { - toast({ - description: `File gambar yang bisa diterima hanya berjumlah ${count} gambar`, - status: "error", - duration: 2500, - isClosable: true, - }); - - return; - } - - if ( - Array.from(files).some( - (file: File) => !extensions.some((format) => file.type === format), - ) - ) { - toast({ - description: - "File harus berupa gambar yang bertipekan jpg, jpeg, png!", - status: "error", - duration: 2500, - isClosable: true, - }); - - return; - } - - if (files && files.length) { - onImageDropped(files); - } - }; - - instance.addEventListener("drop", handleDrop); - - return () => { - instance.removeEventListener("drop", handleDrop); - }; - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return ( - - - - - - {imgFromInput !== null ? ( - // eslint-disable-next-line @next/next/no-img-element - {"Gambar - ) : ( - - - Seret gambar kesini - - - atau klik disini untuk mengunggah - - - )} - - - - - - - ); -} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx deleted file mode 100644 index 7c5ee64b..00000000 --- a/apps/web/src/components/Sidebar.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { - FiHome, - FiSettings, - FiTrendingUp, - FiUser, - FiUsers, -} from "react-icons/fi"; - -import { SidebarWrapper } from "@sora/ui"; - -export default SidebarWrapper([ - { name: "Beranda", icon: FiHome, href: "/" }, - { name: "Kandidat", icon: FiUser, href: "/kandidat" }, - { name: "Partisipan", icon: FiUsers, href: "/peserta" }, - { name: "Statistik", icon: FiTrendingUp, href: "/statistik" }, - { name: "Pengaturan", icon: FiSettings, href: "/pengaturan" }, -]); diff --git a/apps/web/src/components/pages/admin/PengaturanPerilaku.tsx b/apps/web/src/components/pages/admin/PengaturanPerilaku.tsx deleted file mode 100644 index fece5ae6..00000000 --- a/apps/web/src/components/pages/admin/PengaturanPerilaku.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { - Box, - Button, - Container, - FormControl, - // Form - FormErrorMessage, - FormLabel, - Switch, - Text, - useColorModeValue, - useToast, -} from "@chakra-ui/react"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { Controller, useForm } from "react-hook-form"; - -import { - PengaturanPerilakuValidationSchema as validationSchema, - type PengaturanPerilakuFormValues as FormValues, -} from "@sora/schema-config/admin.settings.schema"; - -import { api } from "~/utils/api"; - -const PengaturanPerilaku = () => { - const toast = useToast(); - - const { handleSubmit, control, reset, formState } = useForm({ - resolver: zodResolver(validationSchema), - }); - - const settingsQuery = api.settings.getSettings.useQuery(undefined, { - onSuccess(data) { - reset({ - canVote: data.canVote, - canAttend: data.canAttend, - }); - }, - }); - const changeBehaviour = api.settings.changeVotingBehaviour.useMutation({ - onSuccess(result) { - toast({ - description: result.message, - status: "success", - duration: 6000, - position: "top-right", - isClosable: true, - }); - - settingsQuery.refetch(); - }, - - onError(result) { - toast({ - description: result.message, - status: "error", - duration: 6000, - position: "top-right", - isClosable: true, - }); - }, - }); - - const onSubmit = (data: FormValues) => changeBehaviour.mutate(data); - - return ( - - - - Perilaku Pemilihan - - -
- - Sudah Bisa Memilih - ( - field.onChange(checked)} - isDisabled={ - changeBehaviour.isLoading || settingsQuery.isLoading - } - /> - )} - /> - - {formState.errors?.canVote?.message} - - - - - Sudah Bisa Absen - ( - field.onChange(checked)} - isDisabled={ - changeBehaviour.isLoading || settingsQuery.isLoading - } - /> - )} - /> - - {formState.errors?.canAttend?.message} - - - - -
-
-
- ); -}; - -export default PengaturanPerilaku; diff --git a/apps/web/src/components/pages/admin/PengaturanWaktu.tsx b/apps/web/src/components/pages/admin/PengaturanWaktu.tsx deleted file mode 100644 index 629280e3..00000000 --- a/apps/web/src/components/pages/admin/PengaturanWaktu.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import { - Box, - Button, - Container, - FormControl, - // Form - FormErrorMessage, - FormLabel, - Text, - useColorModeValue, - useToast, -} from "@chakra-ui/react"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { DateTime } from "luxon"; -import { Controller, useForm } from "react-hook-form"; -import { z } from "zod"; - -import { api } from "~/utils/api"; -import DatePicker from "../../DatePicker"; - -const diniHari = DateTime.fromISO( - DateTime.now().toFormat("yyyy-MM-dd"), -).toJSDate(); -const toUTC = (time: Date) => DateTime.fromJSDate(time).toUTC().toJSDate(); - -const validationSchema = z - .object({ - startTime: z - .date({ required_error: "Diperlukan kapan waktu mulai pemilihan!" }) - .min(diniHari, { - message: "Minimal waktu pemilihan adalah hari ini dini hari!", - }), - endTime: z.date({ - required_error: "Diperlukan kapan waktu selesai pemilihan!", - }), - }) - .refine((data) => data.startTime < data.endTime, { - path: ["endTime"], - message: "Waktu selesai tidak boleh kurang dari waktu mulai!", - }); - -type FormValues = z.infer; - -const PengaturanWaktu = () => { - const toast = useToast(); - - const { handleSubmit, getValues, control, reset, formState } = - useForm({ - resolver: zodResolver(validationSchema), - }); - - const settingsQuery = api.settings.getSettings.useQuery(undefined, { - onSuccess(result) { - if (result.startTime && result.endTime) - reset({ - startTime: DateTime.fromJSDate(result.startTime).toLocal().toJSDate(), - endTime: DateTime.fromJSDate(result.endTime).toLocal().toJSDate(), - }); - }, - }); - const changeTime = api.settings.changeVotingTime.useMutation({ - onSuccess(result) { - toast({ - description: result.message, - status: "success", - duration: 6000, - position: "top-right", - isClosable: true, - }); - - settingsQuery.refetch(); - }, - - onError(result) { - toast({ - description: result.message, - status: "error", - duration: 6000, - position: "top-right", - isClosable: true, - }); - }, - }); - - const onSubmit = (data: FormValues) => - changeTime.mutate({ - startTime: toUTC(data.startTime), - endTime: toUTC(data.endTime), - }); - - return ( - - - - Waktu Pemilihan - - -
- - Waktu Mulai - ( - field.onChange(date)} - selectedDate={field.value as Date | null} - filterDate={(day) => day >= diniHari} - disabled={changeTime.isLoading || settingsQuery.isLoading} - customStyles={{ width: "85%" }} - /> - )} - /> - - {formState.errors?.startTime?.message} - - - - - Waktu Selesai - ( - field.onChange(date)} - selectedDate={field.value as Date | null} - filterDate={(day) => day >= diniHari} - disabled={changeTime.isLoading || settingsQuery.isLoading} - filterTime={(time) => { - const selectedDate = new Date(time); - const startTime = getValues("startTime"); - - if (!startTime) return false; - return selectedDate.getTime() > startTime.getTime(); - }} - showTimeSelect - dateFormat="MM/dd/yyyy h:mm aa" - customStyles={{ width: "85%" }} - /> - )} - /> - - {formState.errors?.endTime?.message} - - - - -
-
-
- ); -}; - -export default PengaturanWaktu; diff --git a/apps/web/src/env.mjs b/apps/web/src/env.mjs deleted file mode 100644 index 2c1e742d..00000000 --- a/apps/web/src/env.mjs +++ /dev/null @@ -1,27 +0,0 @@ -import { createEnv } from "@t3-oss/env-nextjs"; -import { z } from "zod"; - -export const env = createEnv({ - /** - * Specify your server-side environment variables schema here. This way you can ensure the app isn't - * built with invalid env vars. - */ - server: { - DATABASE_URL: z.string().url(), - }, - /** - * Specify your client-side environment variables schema here. - * For them to be exposed to the client, prefix them with `NEXT_PUBLIC_`. - */ - client: { - // NEXT_PUBLIC_CLIENTVAR: z.string(), - }, - /** - * Destructure all variables from `process.env` to make sure they aren't tree-shaken away. - */ - runtimeEnv: { - DATABASE_URL: process.env.DATABASE_URL, - // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR, - }, - skipValidation: !!process.env.CI || !!process.env.SKIP_ENV_VALIDATION, -}); diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts deleted file mode 100644 index 342d900a..00000000 --- a/apps/web/src/middleware.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { default } from "next-auth/middleware"; - -export const config = { - matcher: ["/", "/((?!login|register|api|_next/static|favicon.ico).*)"], -}; diff --git a/apps/web/src/pages/_app.tsx b/apps/web/src/pages/_app.tsx deleted file mode 100644 index 3e23256a..00000000 --- a/apps/web/src/pages/_app.tsx +++ /dev/null @@ -1,25 +0,0 @@ -// src/pages/_app.tsx - -import type { AppType } from "next/app"; -import { ChakraProvider } from "@chakra-ui/react"; -import type { Session } from "next-auth"; -import { SessionProvider } from "next-auth/react"; - -import "react-datepicker/dist/react-datepicker.css"; -import "~/components/DatePicker/chakra-support.css"; -import { api } from "~/utils/api"; - -const MyApp: AppType<{ session: Session | null }> = ({ - Component, - pageProps: { session, ...pageProps }, -}) => { - return ( - - - - - - ); -}; - -export default api.withTRPC(MyApp); diff --git a/apps/web/src/pages/api/admin/kandidat.ts b/apps/web/src/pages/api/admin/kandidat.ts deleted file mode 100644 index 0aa3c81b..00000000 --- a/apps/web/src/pages/api/admin/kandidat.ts +++ /dev/null @@ -1,176 +0,0 @@ -import fs from "fs"; -import fsp from "fs/promises"; -import path from "path"; -import type { NextApiRequest, NextApiResponse } from "next"; -import formidable from "formidable"; -import mv from "mv"; -import { createRouter } from "next-connect"; - -import { getServerSession } from "@sora/auth"; -import { prisma } from "@sora/db"; -import { canVoteNow } from "@sora/settings"; - -type grabableImageType = { - newFilename: string; - originalFilename: string; - filepath: string; -}; - -const ROOT_PATH = path.join(path.resolve(), "public/uploads"); - -const router = createRouter() - .use(async (req, res, next) => { - const session = await getServerSession({ req, res }); - - if (!session) - res.status(401).json({ - error: true, - message: "Anda belum terautentikasi!", - type: "UNAUTHENTICATED", - }); - else next(); - }) - .post((req, res) => { - const form = new formidable.IncomingForm(); - - form.parse(req, async (err, fields, files) => { - const { kandidat } = fields as { - kandidat: string; - }; - - if (!kandidat || !files.image) - return res.status(400).json({ - error: true, - message: "Diperlukan nama kandidat dan gambarnya!", - }); - - const inVoteCondition = canVoteNow(); - - if (inVoteCondition) - return res.status(400).json({ - error: true, - message: - "Tidak bisa menambahkan kandidat pada saat kondisi pemilihan", - }); - - const image = files.image as unknown as grabableImageType; - - const splitted = image.originalFilename.split("."); - const ext = splitted[splitted.length - 1]; - const newName = `${image.newFilename}.${ext}`; - - const newPath = path.join(ROOT_PATH, newName); - - mv(image.filepath, newPath, { mkdirp: true }, (err) => { - if (err) - return res - .status(500) - .json({ error: true, message: "Gagal mengupload gambar baru" }); - }); - - try { - await prisma.candidate.create({ - data: { - name: kandidat, - img: newName, - }, - }); - - res - .status(200) - .json({ error: false, message: "Kandidat berhasil dibuat" }); - } catch (e: unknown) { - res.status(500).json({ - error: true, - message: (e as unknown as { toString(): string }).toString(), - }); - } - }); - }) - .put((req, res) => { - const form = new formidable.IncomingForm(); - - form.parse(req, async (err, fields, files) => { - const { kandidat, id } = fields as unknown as { - kandidat: string; - id: string; - }; - - if (!id || !kandidat) - return res.status(400).json({ - error: true, - message: "Diperlukan id dan nama kandidat!", - }); - - if (isNaN(parseInt(id))) - return res.status(400).json({ - error: true, - message: "id tidak valid!", - }); - - const inVoteCondition = canVoteNow(); - - if (inVoteCondition) - return res.status(400).json({ - error: true, - message: "Tidak bisa mengubah kandidat pada saat kondisi pemilihan", - }); - - try { - const candidate = await prisma.candidate.findUnique({ - where: { id: parseInt(id) }, - }); - - if (!candidate) - return res - .status(404) - .json({ error: true, message: "Kandidat tidak ditemukan!" }); - - const image = files.image as unknown as grabableImageType; - - const splitted = image && image.originalFilename.split("."); - const ext = image && splitted[splitted.length - 1]; - const newName = image && `${image.newFilename}.${ext}`; - - if (image) { - const oldImagePath = path.join(ROOT_PATH, candidate.img); - if (fs.existsSync(oldImagePath)) await fsp.unlink(oldImagePath); - - const newPath = image && path.join(ROOT_PATH, newName); - - mv(image.filepath, newPath, { mkdirp: true }, (err) => { - if (err) - return res - .status(500) - .json({ error: true, message: "Gagal mengupload gambar baru" }); - }); - } - - await prisma.candidate.update({ - where: { id: parseInt(id) }, - data: { - name: kandidat, - img: image ? newName : candidate.img, - }, - }); - - res.status(200).json({ - error: false, - message: "Berhasil mengedit kandidat!", - }); - } catch (e: unknown) { - res.status(500).json({ - error: true, - message: (e as unknown as { toString(): string }).toString(), - }); - } - }); - }); - -export const config = { - api: { - bodyParser: false, - }, -}; - -export default router.handler(); diff --git a/apps/web/src/pages/api/auth/[...nextauth].ts b/apps/web/src/pages/api/auth/[...nextauth].ts deleted file mode 100644 index 6346dc33..00000000 --- a/apps/web/src/pages/api/auth/[...nextauth].ts +++ /dev/null @@ -1,5 +0,0 @@ -import NextAuth from "next-auth"; - -import { authOptions } from "@sora/auth"; - -export default NextAuth(authOptions); diff --git a/apps/web/src/pages/api/trpc/[trpc].ts b/apps/web/src/pages/api/trpc/[trpc].ts deleted file mode 100644 index 313fced8..00000000 --- a/apps/web/src/pages/api/trpc/[trpc].ts +++ /dev/null @@ -1,13 +0,0 @@ -import { createNextApiHandler } from "@trpc/server/adapters/next"; - -import { appRouter, createTRPCContext } from "@sora/api"; - -import { withCors } from "~/utils/cors"; - -// export API handler -export default withCors( - createNextApiHandler({ - router: appRouter, - createContext: createTRPCContext, - }), -); diff --git a/apps/web/src/pages/api/uploads/[filename].ts b/apps/web/src/pages/api/uploads/[filename].ts deleted file mode 100644 index 0f431c47..00000000 --- a/apps/web/src/pages/api/uploads/[filename].ts +++ /dev/null @@ -1,24 +0,0 @@ -import fs from "fs"; -import path from "path"; -import type { NextApiRequest, NextApiResponse } from "next"; -import mime from "mime-types"; - -const ROOT_PATH = path.join(path.resolve(), "public/uploads"); - -export default function handler(req: NextApiRequest, res: NextApiResponse) { - const { filename } = req.query as unknown as { filename: string }; - - const safeSuffix = path.normalize(filename).replace(/^(\.\.(\/|\\|$))+/, ""); - const filePath = path.join(ROOT_PATH, safeSuffix); - - if (!fs.existsSync(filePath)) - return res.status(404).json({ error: true, message: "Not Found" }); - - const image = fs.readFileSync(filePath); - const contentType = mime.lookup(filePath); - - res - .setHeader("Content-Type", contentType as string) - .status(200) - .send(image); -} diff --git a/apps/web/src/pages/index.tsx b/apps/web/src/pages/index.tsx deleted file mode 100644 index 244fc6b7..00000000 --- a/apps/web/src/pages/index.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import Head from "next/head"; -import NextLink from "next/link"; -import { - Box, - Button, - Container, - HStack, - Stack, - Text, - VStack, - useColorModeValue, -} from "@chakra-ui/react"; -import { DateTime } from "luxon"; - -import { api } from "~/utils/api"; -import Sidebar from "~/components/Sidebar"; - -const Admin = () => { - const userInfo = api.auth.me.useQuery(); - - return ( - <> - - Dashboard Admin - - - - - Dashboard Admin - - - - - - - - Informasi Akun Anda - - - Nama: {userInfo.data?.name ?? "N/A"} - Email: {userInfo.data?.email ?? "N/A"} - - Tanggal Pendaftaran:{" "} - {(userInfo.data?.createdAt && - DateTime.fromMillis(Number(userInfo.data?.createdAt)) - .setLocale("id-ID") - .toFormat("cccc, dd MMMM yyyy, HH:mm:ss")) ?? - "N/A"} - - - - - - - - - - - - - - - - ); -}; - -export default Sidebar(Admin); diff --git a/apps/web/src/pages/kandidat/edit/[id].tsx b/apps/web/src/pages/kandidat/edit/[id].tsx deleted file mode 100644 index 72ad6e17..00000000 --- a/apps/web/src/pages/kandidat/edit/[id].tsx +++ /dev/null @@ -1,231 +0,0 @@ -import { useEffect, useState } from "react"; -import Head from "next/head"; -import NextLink from "next/link"; -import { useRouter } from "next/router"; -import { - Box, - Button, - FormControl, - // Form - FormErrorMessage, - FormLabel, - HStack, - Input, - Link, - Text, - VStack, - useColorModeValue, - useToast, -} from "@chakra-ui/react"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { Controller, useForm } from "react-hook-form"; - -import { - EditKandidatValidationSchema as validationSchema, - type TEditKandidatValidationSchema as FormValues, -} from "@sora/schema-config/admin.candidate.schema"; - -import { api } from "~/utils/api"; -import InputImageBox from "~/components/InputImageBox"; -import Sidebar from "~/components/Sidebar"; - -const EditCandidateWithID = () => { - const toast = useToast(); - const router = useRouter(); - - const [imgFromInput, setIFI] = useState(null); - - const { handleSubmit, register, formState, reset, watch, control } = - useForm({ - resolver: zodResolver(validationSchema), - }); - - const settingsQuery = api.settings.getSettings.useQuery(undefined, { - onSuccess(result) { - if (result.canVote) router.push("/kandidat"); - }, - }); - const candidateQuery = api.candidate.getSpecificCandidate.useQuery( - { id: Number(router.query.id) }, - { - refetchOnWindowFocus: false, - onSuccess: reset, - onError(result) { - toast({ - description: result.message, - status: "error", - duration: 6000, - position: "top-right", - isClosable: true, - }); - }, - }, - ); - - useEffect(() => { - const subscription = watch((value, { name, type }) => { - if (name === "image" && type === "change") { - const image = (value.image as unknown as { [0]: File })[0]; - - if (image) { - const objectUrl = URL.createObjectURL(image); - setIFI(objectUrl); - } else { - if (imgFromInput !== null) URL.revokeObjectURL(imgFromInput); - - setIFI(null); - } - } - }); - - return () => { - subscription.unsubscribe(); - - if (imgFromInput !== null) URL.revokeObjectURL(imgFromInput); - - setIFI(null); - }; - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [watch]); - - const onSubmit = async (data: FormValues) => { - const formData = new FormData(); - const keys = Object.keys(data); - - formData.append("id", router.query.id as string); - - for (const key of keys) { - if (key === "image" && data.image) - formData.append(key, (data[key] as unknown as { [0]: File })[0]); - else formData.append(key, data[key as keyof FormValues]); - } - - const response = await fetch("/api/admin/kandidat", { - method: "PUT", - body: formData, - headers: { - credentials: "include", - }, - }); - - const result = await response.json(); - - toast({ - description: result.message, - status: result.error ? "error" : "success", - duration: 6000, - position: "top-right", - isClosable: true, - }); - - if (result.error) { - reset(); - - if (imgFromInput !== null) URL.revokeObjectURL(imgFromInput); - setIFI(null); - } else { - router.push("/kandidat"); - } - }; - - return ( - <> - - Ubah Kandidat - - - - - Ubah Kandidat - - - - - -
- - Nama Kandidat - - - {formState.errors?.kandidat?.message} - - - - ( - - field.onChange( - (e.target as unknown as { files: File }).files, - ) - } - onImageDropped={(img) => field.onChange(img)} - /> - )} - /> - - {formState.errors?.image?.message} - - - - - - Kembali - - -
-
-
-
-
- - ); -}; - -export default Sidebar(EditCandidateWithID); diff --git a/apps/web/src/pages/kandidat/index.tsx b/apps/web/src/pages/kandidat/index.tsx deleted file mode 100644 index 2326f90a..00000000 --- a/apps/web/src/pages/kandidat/index.tsx +++ /dev/null @@ -1,367 +0,0 @@ -import { useRef, useState } from "react"; -import Head from "next/head"; -import NextLink from "next/link"; -import { - // Alert dialog - AlertDialog, - AlertDialogBody, - AlertDialogCloseButton, - AlertDialogContent, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogOverlay, - Badge, - Box, - Button, - HStack, - Spinner, - Stack, - // Table - Table, - TableCaption, - TableContainer, - Tbody, - Td, - Text, - Th, - Thead, - Tooltip, - Tr, - VStack, - useColorModeValue, - useDisclosure, - useToast, -} from "@chakra-ui/react"; - -import { api } from "~/utils/api"; -import Sidebar from "~/components/Sidebar"; - -const Candidate = () => { - const toast = useToast(); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const cancelRef = useRef(null!); - - const { isOpen, onOpen, onClose } = useDisclosure(); - - const candidateQuery = api.candidate.adminCandidateList.useQuery(undefined, { - refetchInterval: 2500, - refetchIntervalInBackground: true, - }); - const settingsQuery = api.settings.getSettings.useQuery(undefined, { - refetchInterval: 2500, - refetchIntervalInBackground: true, - }); - const counterQuery = api.candidate.getCandidateAndParticipantCount.useQuery( - undefined, - { - refetchInterval: 2500, - refetchIntervalInBackground: true, - }, - ); - - const candidateDeleteMutation = - api.candidate.adminDeleteCandidate.useMutation({ - onSuccess(result) { - onClose(); - - toast({ - description: result.message, - status: "success", - duration: 6000, - position: "top-right", - isClosable: true, - }); - }, - - onError(result) { - toast({ - description: result.message, - status: "error", - duration: 6000, - position: "top-right", - isClosable: true, - }); - }, - }); - - // Untuk keperluan hapus data - const [currentID, setID] = useState(null); - - const getNama = () => { - const currentCandidate = candidateQuery.data?.find( - (p) => p.id === currentID, - ); - - return currentCandidate?.name; - }; - - return ( - <> - - Daftar Kandidat - - - - - Kandidat - - - - - - - - - - - - - - - - - - {!candidateQuery.isLoading && - !candidateQuery.isError && - candidateQuery.data.length > 0 && - counterQuery.data ? ( - - - - Akumulasi Kandidat: - {" "} - {counterQuery.data.candidates} Orang - - - - Akumulasi Pemilih: - {" "} - {counterQuery.data.participants} Orang - - - - {counterQuery.data.isMatch - ? "COCOK!" - : "TIDAK COCOK!"} - - - - ) : null} - - - {!candidateQuery.isLoading && - !candidateQuery.isError && - candidateQuery.data.length < 1 ? ( - - Tidak ada kandidat yang tersedia, silahkan tambahkan - kandidat terlebih dahulu. - - ) : null} - - - - - - - - - - - - {candidateQuery.isLoading && ( - - - - )} - - {!candidateQuery.isLoading && - !candidateQuery.isError && - candidateQuery.data && - candidateQuery.data.map((p, idx) => ( - - - - - - - - ))} - - {!candidateQuery.isLoading && !candidateQuery.data && ( - - - - )} - -
#Nama KandidatYang MemilihGambarAksi
- -
{++idx}{p.name}{p.counter} Orang - {/* eslint-disable-next-line @next/next/no-img-element */} - {`Gambar - - - - - -
- Tidak ada data kandidat, Silahkan tambah kandidat - baru dengan tombol di atas. -
-
-
-
-
-
-
- { - if (!candidateDeleteMutation.isLoading) { - setID(null); - onClose(); - } - }} - > - - - {!candidateDeleteMutation.isLoading && } - - Hapus Kandidat - - - - Apakah anda yakin? Jika sudah terhapus maka kandidat {getNama()}{" "} - TIDAK BISA DIPILIH, DIREVISI, DAN DIKEMBALIKAN LAGI! - - - - - - - - - - - ); -}; - -export default Sidebar(Candidate); diff --git a/apps/web/src/pages/kandidat/status.tsx b/apps/web/src/pages/kandidat/status.tsx deleted file mode 100644 index c6c9388a..00000000 --- a/apps/web/src/pages/kandidat/status.tsx +++ /dev/null @@ -1,191 +0,0 @@ -import Head from "next/head"; -import { - Badge, - Box, - Flex, - HStack, - Text, - Tooltip, - VStack, - useColorModeValue, -} from "@chakra-ui/react"; - -import { api } from "~/utils/api"; -import Sidebar from "~/components/Sidebar"; - -const Status = () => { - const candidateQuery = api.candidate.adminCandidateList.useQuery(undefined, { - refetchInterval: 2500, - refetchIntervalInBackground: true, - }); - - const counterQuery = api.candidate.getCandidateAndParticipantCount.useQuery( - undefined, - { - refetchInterval: 2500, - refetchIntervalInBackground: true, - }, - ); - - return ( - <> - - Status Pemilihan - - - - - Status Pemilihan - - - - - - - - Akumulasi Kandidat - - - - - {!candidateQuery.isLoading && - !candidateQuery.isError && - candidateQuery.data.length > 0 && - counterQuery.data ? ( - - {counterQuery.data.candidates} Orang - - ) : ( - <> - - N/A - - - Belum di setup. - - )} - - - - - - - - - Akumulasi Pemilih - - - - - {!candidateQuery.isLoading && - !candidateQuery.isError && - candidateQuery.data.length > 0 && - counterQuery.data ? ( - - {counterQuery.data.participants} Orang - - ) : ( - <> - - N/A - - - Belum di setup. - - )} - - - - - - - - - Kecocokan Data - - - - - {!candidateQuery.isLoading && - !candidateQuery.isError && - candidateQuery.data.length > 0 && - counterQuery.data ? ( - - - {counterQuery.data.isMatch ? "COCOK!" : "TIDAK COCOK!"} - - - ) : ( - <> - - N/A - - - Belum di setup. - - )} - - - - - - - ); -}; - -export default Sidebar(Status); diff --git a/apps/web/src/pages/kandidat/tambah.tsx b/apps/web/src/pages/kandidat/tambah.tsx deleted file mode 100644 index b2a96728..00000000 --- a/apps/web/src/pages/kandidat/tambah.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import { useEffect, useState } from "react"; -import Head from "next/head"; -import NextLink from "next/link"; -import Router from "next/router"; -import { - Box, - Button, - FormControl, - // Form - FormErrorMessage, - FormLabel, - HStack, - Input, - Link, - Text, - VStack, - useColorModeValue, - useToast, -} from "@chakra-ui/react"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { Controller, useForm } from "react-hook-form"; - -import { - TambahKandidatValidationSchema as validationSchema, - type TambahFormValues as FormValues, -} from "@sora/schema-config/admin.candidate.schema"; - -import { api } from "~/utils/api"; -import InputImageBox from "~/components/InputImageBox"; -import Sidebar from "~/components/Sidebar"; - -const HalamanTambah = () => { - const toast = useToast(); - const [imgFromInput, setIFI] = useState(null); - - const settingsQuery = api.settings.getSettings.useQuery(undefined, { - onSuccess(result) { - if (result.canVote) Router.push("/kandidat"); - }, - }); - - const { handleSubmit, register, formState, control, reset, watch } = - useForm({ - resolver: zodResolver(validationSchema), - }); - - useEffect(() => { - const subscription = watch((value, { name, type }) => { - if (name === "image" && type === "change") { - const image = (value.image as unknown as { [0]: File })[0]; - - if (image) { - const objectUrl = URL.createObjectURL(image); - setIFI(objectUrl); - } else { - if (imgFromInput !== null) URL.revokeObjectURL(imgFromInput); - - setIFI(null); - } - } - }); - - return () => { - subscription.unsubscribe(); - - if (imgFromInput !== null) URL.revokeObjectURL(imgFromInput); - - setIFI(null); - }; - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [watch]); - - const onSubmit = async (data: FormValues) => { - const formData = new FormData(); - const keys = Object.keys(data); - - for (const key of keys) { - if (key === "image") - formData.append(key, (data[key] as unknown as { [0]: File })[0]); - else formData.append(key, data[key as keyof FormValues]); - } - - const response = await fetch("/api/admin/kandidat", { - method: "POST", - body: formData, - headers: { - credentials: "include", - }, - }); - - const result = await response.json(); - - toast({ - description: result.message, - status: result.error ? "error" : "success", - duration: 6000, - position: "top-right", - isClosable: true, - }); - - if (result.error) { - reset(); - - if (imgFromInput !== null) URL.revokeObjectURL(imgFromInput); - setIFI(null); - } else Router.push("/kandidat"); - }; - - return ( - <> - - Tambah Kandidat - - - - - Tambah Kandidat Baru - - - - - -
- - Nama Kandidat - - - {formState.errors?.kandidat?.message} - - - - - ( - - field.onChange( - (e.target as unknown as { files: File }).files, - ) - } - onImageDropped={(img) => field.onChange(img)} - /> - )} - /> - - {formState.errors?.image?.message} - - - - - - Kembali - - -
-
-
-
-
- - ); -}; - -export default Sidebar(HalamanTambah); diff --git a/apps/web/src/pages/login.tsx b/apps/web/src/pages/login.tsx deleted file mode 100644 index be87698d..00000000 --- a/apps/web/src/pages/login.tsx +++ /dev/null @@ -1,196 +0,0 @@ -/* eslint-disable react-hooks/rules-of-hooks */ - -import { useEffect, useState } from "react"; -import type { NextPage } from "next"; -import Head from "next/head"; -import Link from "next/link"; -import { useRouter } from "next/router"; -import { - Box, - Button, - Flex, - FormControl, - FormErrorMessage, - FormLabel, - Heading, - Input, - Spinner, - Text, - useColorModeValue, - useToast, -} from "@chakra-ui/react"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { getSession, signIn } from "next-auth/react"; -import { useForm } from "react-hook-form"; - -import { - LoginSchemaValidator, - type LoginType, -} from "@sora/schema-config/auth.schema"; - -const LoginSvg = () => ( - - - -); - -const Login: NextPage = () => { - const [isLoading, setLoading] = useState(true); - - const toast = useToast(); - const router = useRouter(); - - const { handleSubmit, register, formState } = useForm({ - resolver: zodResolver(LoginSchemaValidator), - }); - - const onSubmit = async (data: LoginType) => { - const result = await signIn("credentials", { - redirect: false, - email: data.email, - password: data.password, - }); - - if (result?.ok) { - toast.closeAll(); - toast({ - description: "Berhasil login", - status: "success", - duration: 4500, - position: "top-right", - isClosable: false, - }); - - if (!router.query?.callbackUrl) return router.replace("/"); - - const url = new URL(router.query?.callbackUrl as string); - router.replace(url.pathname as string); - } else { - toast({ - description: result?.error, - status: "error", - duration: 4500, - position: "top-right", - isClosable: false, - }); - } - }; - - useEffect(() => { - router.prefetch("/"); - - getSession().then((session) => { - if (session) { - router.replace("/"); - } else { - setLoading(false); - } - }); - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - if (isLoading) - return ( - - - Login sebagai admin - - - - - - - ); - - return ( - - - Login sebagai admin - - - - Login Administrator - - -
- - Email - - - {formState.errors?.email?.message} - - - - Kata Sandi - - - {formState.errors?.password?.message} - - - -
- - Belum punya akun admin ?{" "} - - Daftar - - -
-
- -
- ); -}; - -export default Login; diff --git a/apps/web/src/pages/pengaturan.tsx b/apps/web/src/pages/pengaturan.tsx deleted file mode 100644 index cbd27cbe..00000000 --- a/apps/web/src/pages/pengaturan.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import Head from "next/head"; -import { HStack, Text, VStack } from "@chakra-ui/react"; - -import Sidebar from "~/components/Sidebar"; -import PengaturanPerilaku from "~/components/pages/admin/PengaturanPerilaku"; -import PengaturanWaktu from "~/components/pages/admin/PengaturanWaktu"; - -const Pengaturan = () => { - return ( - <> - - Pengaturan - - - - - Pengaturan - - - - - - - - - ); -}; - -export default Sidebar(Pengaturan); diff --git a/apps/web/src/pages/peserta/csv.tsx b/apps/web/src/pages/peserta/csv.tsx deleted file mode 100644 index 7ec7dc60..00000000 --- a/apps/web/src/pages/peserta/csv.tsx +++ /dev/null @@ -1,234 +0,0 @@ -import { useState } from "react"; -import Head from "next/head"; -import NextLink from "next/link"; -import Router from "next/router"; -import { - Box, - Button, - FormControl, - // Form - FormErrorMessage, - FormLabel, - HStack, - Input, - Link, - ListItem, - // Modal - Modal, - ModalBody, - ModalCloseButton, - ModalContent, - ModalFooter, - ModalHeader, - ModalOverlay, - Text, - //List - UnorderedList, - VStack, - // Hook - useColorModeValue, - useDisclosure, - useToast, -} from "@chakra-ui/react"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { parse as parseCSV } from "csv-parse"; -import { useForm } from "react-hook-form"; - -import { - TambahPesertaManyValidationSchema as CSVDataValidator, - UploadPartisipanValidationSchema as validationSchema, - type TUploadFormValues as FormValues, -} from "@sora/schema-config/admin.participant.schema"; - -import { api } from "~/utils/api"; -import Sidebar from "~/components/Sidebar"; - -type StateZodErr = { - error: Array<{ message: string; path: Array }>; - dataOfError: Array<{ "Bagian Dari": string; Nama: string }>; -}; - -const HalamanTambah = () => { - const toast = useToast(); - const [errors, setErr] = useState(null); - - const { isOpen, onOpen, onClose } = useDisclosure(); - - const insertManyMutation = api.participant.insertManyParticipant.useMutation({ - onSuccess(result) { - toast({ - description: result.message, - status: "success", - duration: 6000, - position: "top-right", - isClosable: true, - }); - - Router.push("/peserta"); - }, - - onError(result) { - toast({ - description: result.message, - status: "error", - duration: 6000, - position: "top-right", - isClosable: true, - }); - }, - }); - - const { handleSubmit, register, formState } = useForm({ - resolver: zodResolver(validationSchema), - }); - - const onSubmit = async (data: FormValues) => { - const file = data.csv.item(0) as File; - const text = await file.text(); // Already checked - - parseCSV(text, { columns: true, trim: true }, (err, records) => { - if (err) - toast({ - description: err.message, - status: "error", - duration: 6000, - position: "top-right", - isClosable: true, - }); - - const result = CSVDataValidator.safeParse(records); - - if (!result.success) { - const error = JSON.parse(result.error.message) as StateZodErr["error"]; - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const dataOfError = error.map((d) => records[d.path[0]]); - - setErr({ - error, - dataOfError: dataOfError as StateZodErr["dataOfError"], - }); - - return onOpen(); - } - - console.log(result.data); - - insertManyMutation.mutate(result.data); - }); - }; - - return ( - <> - - Tambah Peserta via Upload - - - - - Upload File CSV - - - - - -
- - File CSV - - - {formState.errors?.csv?.message} - - - - - - - Kembali - - -
-
-
-
-
- { - onClose(); - setErr(null); - }} - > - - - Gagal Upload Peserta - - - - Gagal mengunggah data peserta pemilihan dikarenakan format yang - tidak sesuai dengan yang diharapkan. - - - - Berikut ini daftar yang tidak sesuai: - - - - {errors?.error.map((error, idx) => ( - - Kolom {error.path[1]} data{" "} - {/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */} - {/** @ts-ignore */} - {JSON.stringify(errors.dataOfError[idx][error.path[1]])}.{" "} - {error.message} - - ))} - - - - - - - - - - ); -}; - -export default Sidebar(HalamanTambah); diff --git a/apps/web/src/pages/peserta/edit/[qrId].tsx b/apps/web/src/pages/peserta/edit/[qrId].tsx deleted file mode 100644 index eb47caa1..00000000 --- a/apps/web/src/pages/peserta/edit/[qrId].tsx +++ /dev/null @@ -1,174 +0,0 @@ -import Head from "next/head"; -import NextLink from "next/link"; -import { useRouter } from "next/router"; -import { - Box, - Button, - FormControl, - // Form - FormErrorMessage, - FormLabel, - HStack, - Input, - Link, - Text, - VStack, - useColorModeValue, - useToast, -} from "@chakra-ui/react"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; - -import { - TambahPesertaValidationSchema as validationSchema, - type TambahFormValues as FormValues, -} from "@sora/schema-config/admin.participant.schema"; - -import { api } from "~/utils/api"; -import Sidebar from "~/components/Sidebar"; - -const EditPesertaWithID = () => { - const toast = useToast(); - const router = useRouter(); - - const { handleSubmit, register, formState, reset } = useForm({ - resolver: zodResolver(validationSchema), - }); - - const specificParticipantQuery = - api.participant.getSpecificParticipant.useQuery( - router.query.qrId as string, - { - refetchOnWindowFocus: false, - onSuccess: reset, - onError(result) { - toast({ - description: result.message, - status: "error", - duration: 6000, - position: "top-right", - isClosable: true, - }); - }, - }, - ); - - const participantMutation = api.participant.updateParticipant.useMutation({ - onSuccess(result) { - toast({ - description: result.message, - status: "success", - duration: 6000, - position: "top-right", - isClosable: true, - }); - - router.push("/peserta"); - }, - onError(result) { - toast({ - description: result.message, - status: "error", - duration: 6000, - position: "top-right", - isClosable: true, - }); - }, - }); - - const onSubmit = (data: FormValues) => - participantMutation.mutate({ - name: data.name, - subpart: data.subpart, - qrId: router.query.qrId as string, - }); - - return ( - <> - - Ubah Peserta - - - - - Ubah Informasi Peserta - - - - - -
- - Nama Peserta - - - {formState.errors?.name?.message} - - - - - Peserta Bagian Dari - - - {formState.errors?.subpart?.message} - - - - - - - Kembali - - -
-
-
-
-
- - ); -}; - -export default Sidebar(EditPesertaWithID); diff --git a/apps/web/src/pages/peserta/index.tsx b/apps/web/src/pages/peserta/index.tsx deleted file mode 100644 index cc5ed3a1..00000000 --- a/apps/web/src/pages/peserta/index.tsx +++ /dev/null @@ -1,688 +0,0 @@ -import { useMemo, useRef, useState } from "react"; -import Head from "next/head"; -import NextLink from "next/link"; -import { - // Alert dialog - AlertDialog, - AlertDialogBody, - AlertDialogCloseButton, - AlertDialogContent, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogOverlay, - Box, - Button, - Flex, - HStack, - IconButton, - NumberDecrementStepper, - NumberIncrementStepper, - NumberInput, - NumberInputField, - NumberInputStepper, - Select, - Spinner, - // Table - Table, - TableContainer, - Tbody, - Td, - Text, - Th, - Thead, - Tooltip, - Tr, - VStack, - useColorModeValue, - useDisclosure, - useToast, -} from "@chakra-ui/react"; -import { - createColumnHelper, - flexRender, - getCoreRowModel, - useReactTable, - type PaginationState, -} from "@tanstack/react-table"; -import xlsx from "json-as-xlsx"; -import { BiFirstPage, BiLastPage } from "react-icons/bi"; -import { - BsFiletypeCsv, - BsFiletypeJson, - BsFiletypeXlsx, - BsFillFilePdfFill, - BsQrCode, -} from "react-icons/bs"; -import { GrNext, GrPrevious } from "react-icons/gr"; - -import { api, type RouterOutputs } from "~/utils/api"; -import Sidebar from "~/components/Sidebar"; - -const columnHelper = - createColumnHelper< - RouterOutputs["participant"]["getParticipantPaginated"]["participants"][number] - >(); - -const columns = [ - columnHelper.accessor((row) => row.name, { - id: "Nama", - }), - columnHelper.accessor((row) => row.subpart, { - id: "Bagian Dari", - }), - columnHelper.accessor((row) => row.qrId, { - id: "QR ID", - cell: (info) => { - // eslint-disable-next-line react-hooks/rules-of-hooks - const toast = useToast(); - - return ( - - { - navigator.clipboard.writeText(info.getValue()); - toast({ - description: "Berhasil menyalin QR ID!", - status: "success", - isClosable: true, - duration: 5000, - }); - }} - > - {info.getValue()} - - - ); - }, - }), - columnHelper.accessor( - (row) => ({ - alreadyAttended: row.alreadyAttended, - attendedAt: row.attendedAt, - }), - { - cell: (info) => - info.getValue().alreadyAttended ? ( - - - ✅ - - - ) : ( - - ❌ - - ), - header: "Sudah Absen", - }, - ), - - columnHelper.accessor( - (row) => ({ - alreadyChoosing: row.alreadyChoosing, - choosingAt: row.choosingAt, - }), - { - cell: (info) => - info.getValue().alreadyChoosing ? ( - - - ✅ - - - ) : ( - - ❌ - - ), - header: "Sudah Memilih", - }, - ), -]; - -const Peserta = () => { - const toast = useToast(); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const cancelRef = useRef(null!); - - const { isOpen, onOpen, onClose } = useDisclosure(); - - const [{ pageIndex, pageSize }, setPagination] = useState({ - pageIndex: 0, - pageSize: 10, - }); - - const pagination = useMemo( - () => ({ - pageIndex, - pageSize, - }), - [pageIndex, pageSize], - ); - - const participantQuery = api.participant.getParticipantPaginated.useQuery( - { - pageIndex: pageIndex > 0 ? pageIndex * pageSize : 0, - pageSize, - }, - { - onError(result) { - toast({ - description: result.message, - status: "error", - duration: 6000, - position: "top-right", - }); - }, - refetchInterval: 5000, - refetchOnWindowFocus: false, - }, - ); - const settingsQuery = api.settings.getSettings.useQuery(undefined, { - refetchInterval: 5000, - refetchIntervalInBackground: true, - onError(result) { - toast({ - description: result.message, - status: "error", - duration: 6000, - position: "top-right", - }); - }, - }); - - const exportJsonQuery = api.participant.exportJsonData.useQuery(undefined, { - refetchOnMount: false, - refetchOnWindowFocus: false, - enabled: false, - - onSuccess({ data }) { - const element = document.createElement("a"); - - element.setAttribute( - "href", - "data:application/json;charset=utf-8," + encodeURIComponent(data), - ); - element.setAttribute("download", "data-partisipan.json"); - - element.click(); - }, - }); - - const exportXlsxQuery = api.participant.exportJsonAsXlsxData.useQuery( - undefined, - { - refetchOnMount: false, - refetchOnWindowFocus: false, - enabled: false, - - onSuccess(data) { - const sheets = [...new Set(data.map(({ subpart }) => subpart))]; - - const xlsxData = sheets.map((sheet) => ({ - sheet, - columns: [ - { label: "Nama Peserta", value: "name" }, - { label: "QR ID", value: "qrId" }, - ], - content: data.filter((participant) => participant.subpart === sheet), - })); - - xlsx(xlsxData, { fileName: "Data Peserta Pemilihan" }); - }, - }, - ); - - const participantDeleteMutation = - api.participant.deleteParticipant.useMutation({ - onSuccess(result) { - onClose(); - - toast({ - description: result.message, - status: "success", - duration: 6000, - position: "top-right", - isClosable: true, - }); - }, - - onError(result) { - toast({ - description: result.message, - status: "error", - duration: 6000, - position: "top-right", - isClosable: true, - }); - }, - }); - - const table = useReactTable({ - data: participantQuery.data?.participants ?? [], - columns, - pageCount: participantQuery.data?.pageCount ?? -1, - state: { - pagination, - }, - onPaginationChange: setPagination, - getCoreRowModel: getCoreRowModel(), - manualPagination: true, - }); - - // Untuk keperluan hapus data - const [currentID, setID] = useState(null); - - const getNama = () => { - const currentParticipant = - participantQuery.data && - participantQuery.data.participants && - participantQuery.data.participants.find((p) => p.id === currentID); - - return currentParticipant?.name; - }; - - return ( - <> - - Peserta Pemilih - - - - - - - - Peserta Pemilih - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - ))} - - - - ))} - - - {participantQuery.isLoading && ( - - - - )} - - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - ))} - - - - ))} - - {(!participantQuery.isLoading && - !participantQuery.data) || - (participantQuery.data && - participantQuery.data.participants.length < 1 && ( - - - - ))} - -
- {flexRender( - header.column.columnDef.header, - header.getContext(), - )} - Aksi
- -
- {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} - - - - - - -
- Tidak ada data peserta, Silahkan tambah peserta - baru dengan tombol di atas. -
- - {participantQuery.data && - participantQuery.data.participants.length > 0 && ( - - - - table.setPageIndex(0)} - isDisabled={!table.getCanPreviousPage()} - icon={} - mr={4} - /> - - - } - /> - - - - - - Halaman{" "} - - {pageIndex + 1} - {" "} - dari{" "} - - {table.getPageCount()} - - - Ke halaman:{" "} - { - const page = value ? Number(value) - 1 : 0; - table.setPageIndex(page); - }} - value={pageIndex + 1} - > - - - - - - - - - - - - } - /> - - - - table.setPageIndex(table.getPageCount() - 1) - } - isDisabled={!table.getCanNextPage()} - icon={} - ml={4} - /> - - - - )} -
-
-
-
-
-
- - { - if (!participantDeleteMutation.isLoading) { - setID(null); - onClose(); - } - }} - > - - - {!participantDeleteMutation.isLoading && } - - Hapus Peserta - - - - Apakah anda yakin? Jika sudah terhapus maka peserta {getNama()}{" "} - TIDAK BISA MEMILIH, DIREVISI, DAN DIKEMBALIKAN LAGI! - - - - - - - - - - - ); -}; - -export default Sidebar(Peserta); diff --git a/apps/web/src/pages/peserta/pdf.tsx b/apps/web/src/pages/peserta/pdf.tsx deleted file mode 100644 index 1b6d86cc..00000000 --- a/apps/web/src/pages/peserta/pdf.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { useMemo, useState } from "react"; -import Head from "next/head"; -import { - Button, - Flex, - HStack, - Heading, - Icon, - Input, - Link, - Select, - Table, - Tbody, - Td, - Text, - Th, - Thead, - Tr, - VStack, - useColorMode, -} from "@chakra-ui/react"; -import { BsMoonFill, BsSun } from "react-icons/bs"; - -import { api } from "~/utils/api"; - -const PDFPage = () => { - const { colorMode, toggleColorMode } = useColorMode(); - - const [subpartValue, setSubpart] = useState(""); - const [mainWeb, setMainWeb] = useState(""); - - const subpartsQuery = api.participant.subparts.useQuery(undefined, { - refetchOnReconnect: false, - refetchOnWindowFocus: false, - }); - - const participantBySubpartQuery = - api.participant.getParticipantBySubpart.useQuery( - { - subpart: subpartValue, - }, - { - refetchOnReconnect: false, - refetchOnWindowFocus: false, - }, - ); - - const allParticipants = useMemo( - () => - participantBySubpartQuery.data?.participants.map((participant) => ({ - ...participant, - link: `${mainWeb}/qr/${participant.qrId}`, - })) ?? [], - [participantBySubpartQuery.data?.participants, mainWeb], - ); - - return ( - <> - - {`Cetak PDF${ - subpartValue !== "" ? ` - ${subpartValue}` : "" - }`} - - - - - - - Peserta Pemilihan - - - - - - { - if (e.target.value === "") { - setMainWeb(""); - return; - } - - try { - const { origin } = new URL(e.target.value); - setMainWeb(origin); - } catch (e) { - setMainWeb(""); - } - }} - /> - - - - - - - - - - - - - - {allParticipants.map((participant, idx) => ( - - - - - - - ))} - -
#NamaQR IDLink QR
{++idx}{participant.name} - {participant.qrId} - - - Klik Disini - -
-
- - ); -}; - -export default PDFPage; diff --git a/apps/web/src/pages/peserta/qr.tsx b/apps/web/src/pages/peserta/qr.tsx deleted file mode 100644 index 2da15b7d..00000000 --- a/apps/web/src/pages/peserta/qr.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { useCallback, useRef, useState } from "react"; -import Head from "next/head"; -import { - Box, - FormControl, - FormHelperText, - FormLabel, - HStack, - Input, - Text, - VStack, - useColorModeValue, -} from "@chakra-ui/react"; -import debounce from "lodash/debounce"; -import QRCode from "qrcode"; - -import Sidebar from "~/components/Sidebar"; - -const QRCodePage = () => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const canvasRef = useRef(null!); - - const [qrInput, setQrInput] = useState(""); - - // eslint-disable-next-line react-hooks/exhaustive-deps - const debouncedFn = useCallback( - debounce((qrValue: string) => { - if (qrValue === "") { - const ctx = canvasRef.current.getContext("2d"); - ctx?.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height); - - return; - } - - QRCode.toCanvas(canvasRef.current, qrValue, { width: 296 }); - }, 500), - [], - ); - - return ( - <> - - Buat QR - - - - - Buat QR Dadakan - - - - - - - - Text QR Code - { - setQrInput(e.target.value.trim()); - debouncedFn(e.target.value.trim()); - }} - /> - - Masukan text QR ID yang ingin dijadikan gambar QR Code - - - - - - - - - - ); -}; - -export default Sidebar(QRCodePage); diff --git a/apps/web/src/pages/peserta/tambah.tsx b/apps/web/src/pages/peserta/tambah.tsx deleted file mode 100644 index 3229fefd..00000000 --- a/apps/web/src/pages/peserta/tambah.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import Head from "next/head"; -import NextLink from "next/link"; -import Router from "next/router"; -import { - Box, - Button, - FormControl, - // Form - FormErrorMessage, - FormLabel, - HStack, - Input, - Link, - Text, - VStack, - useColorModeValue, - useToast, -} from "@chakra-ui/react"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; - -import { - TambahPesertaValidationSchema as validationSchema, - type TambahFormValues as FormValues, -} from "@sora/schema-config/admin.participant.schema"; - -import { api } from "~/utils/api"; -import Sidebar from "~/components/Sidebar"; - -const HalamanTambah = () => { - const toast = useToast(); - - const participantMutation = api.participant.createNewParticipant.useMutation({ - onSuccess(result) { - toast({ - description: result.message, - status: "success", - duration: 6000, - position: "top-right", - isClosable: true, - }); - - Router.push("/peserta"); - }, - - onError(result) { - toast({ - description: result.message, - status: "error", - duration: 6000, - position: "top-right", - isClosable: true, - }); - }, - }); - - const { handleSubmit, register, formState } = useForm({ - resolver: zodResolver(validationSchema), - }); - - const onSubmit = (data: FormValues) => participantMutation.mutate(data); - - return ( - <> - - Tambah Peserta - - - - - Tambah Peserta Baru - - - - - -
- - Nama Peserta - - - {formState.errors?.name?.message} - - - - - Peserta Bagian Dari - - - {formState.errors?.subpart?.message} - - - - - - - Kembali - - -
-
-
-
-
- - ); -}; - -export default Sidebar(HalamanTambah); diff --git a/apps/web/src/pages/register.tsx b/apps/web/src/pages/register.tsx deleted file mode 100644 index 96907c14..00000000 --- a/apps/web/src/pages/register.tsx +++ /dev/null @@ -1,233 +0,0 @@ -/* eslint-disable react-hooks/rules-of-hooks */ - -import { useEffect, useState } from "react"; -import type { NextPage } from "next"; -import Head from "next/head"; -import Link from "next/link"; -import { useRouter } from "next/router"; -import { - Box, - Button, - Flex, - FormControl, - FormErrorMessage, - FormLabel, - HStack, - Heading, - Input, - Spinner, - Text, - useColorModeValue, - useToast, -} from "@chakra-ui/react"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { getSession } from "next-auth/react"; -import { useForm } from "react-hook-form"; - -import { - ClientRegisterSchemaValidator, - type ClientRegisterType, -} from "@sora/schema-config/auth.schema"; - -import { api } from "~/utils/api"; - -const RegisterSvg = () => ( - - - -); - -const Register: NextPage = () => { - const [isLoading, setLoading] = useState(true); - - const toast = useToast(); - const router = useRouter(); - - const registerUser = api.auth.register.useMutation({ - onSuccess(data) { - if (data.success) { - toast.closeAll(); - toast({ - description: - "Berhasil mendaftarkan akun baru! Silahkan login terlebih dahulu.", - status: "success", - duration: 4500, - position: "top-right", - isClosable: false, - }); - - router.push("/login"); - } - }, - onError(error) { - toast({ - description: error.message, - status: "error", - duration: 4500, - position: "top-right", - isClosable: false, - }); - }, - }); - - const { handleSubmit, register, formState } = useForm({ - resolver: zodResolver(ClientRegisterSchemaValidator), - }); - - const onSubmit = (val: ClientRegisterType) => - registerUser.mutate({ - email: val.email, - password: val.password, - name: val.name, - }); - - useEffect(() => { - router.prefetch("/"); - - getSession().then((session) => { - if (session) { - router.replace("/"); - } else { - setLoading(false); - } - }); - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - if (isLoading) - return ( - - - Daftarkan Akun Admin - - - - - - - ); - - return ( - - - Daftarkan Akun Admin - - - - Register Administrator - - -
- - Email - - - {formState.errors?.email?.message} - - - - Nama Lengkap - - - {formState.errors?.name?.message} - - - - - Kata Sandi - - - {formState.errors?.password?.message} - - - - - Konfirmasi kata Sandi - - - - {formState.errors?.passConfirm?.message} - - - - -
- - Sudah punya akun admin ?{" "} - - Login - - -
-
- -
- ); -}; - -export default Register; diff --git a/apps/web/src/pages/statistik.tsx b/apps/web/src/pages/statistik.tsx deleted file mode 100644 index 14902374..00000000 --- a/apps/web/src/pages/statistik.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import Head from "next/head"; -import NextLink from "next/link"; -import { - Box, - HStack, - Link, - Spinner, - Text, - VStack, - useColorModeValue, -} from "@chakra-ui/react"; -import { - Bar, - BarChart, - CartesianGrid, - Legend, - Tooltip, - XAxis, - YAxis, -} from "recharts"; - -import { api } from "~/utils/api"; -import Sidebar from "~/components/Sidebar"; - -const Statistik = () => { - const [width, setWidth] = useState(0); - const [height, setHeight] = useState(0); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const container = useRef(null!); - - const candidateQuery = api.candidate.statisticList.useQuery(undefined, { - refetchInterval: 2500, - refetchIntervalInBackground: true, - }); - - const chartData = useMemo( - () => - candidateQuery.data?.map((kandidat) => ({ - name: kandidat.name, - ["Yang Memilih"]: kandidat.counter, - })), - [candidateQuery.data], - ); - - const tooltipColor = useColorModeValue("white", "#171923"); - const yangMemilihColor = useColorModeValue("#2F855A", "#38A169"); - - useEffect(() => { - const setSize = () => { - setWidth(container.current.clientWidth - 50); - setHeight(container.current.clientHeight - 13); - }; - setSize(); - - window.addEventListener("resize", setSize); - - return () => { - window.removeEventListener("resize", setSize); - }; - }, []); - - return ( - <> - - Statistik Pemilihan - - - - - Statistik - - - - - - - {candidateQuery.isLoading && ( - - )} - - {!candidateQuery.isLoading && - candidateQuery.data && - candidateQuery.data.length < 1 && ( - - - Belum ada kandidat, buat terlebih dahulu di{" "} - - halaman kandidat - - ! - - - )} - - {!candidateQuery.isLoading && - candidateQuery.data && - candidateQuery.data.length > 0 && ( - - - - - - - - - )} - - - - - - ); -}; - -export default Sidebar(Statistik); diff --git a/apps/web/src/pages/ubah/nama.tsx b/apps/web/src/pages/ubah/nama.tsx deleted file mode 100644 index 662aef6c..00000000 --- a/apps/web/src/pages/ubah/nama.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import Head from "next/head"; -import NextLink from "next/link"; -import Router from "next/router"; -import { - Box, - Button, - FormControl, - // Form - FormErrorMessage, - FormLabel, - HStack, - Input, - Link, - Text, - VStack, - useColorModeValue, - useToast, -} from "@chakra-ui/react"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; - -import { - ChangeNameSchemaValidator as validationSchema, - type ChangeNameType as FormValues, -} from "@sora/schema-config/auth.schema"; - -import { api } from "~/utils/api"; -import Sidebar from "~/components/Sidebar"; - -const UbahNama = () => { - const toast = useToast(); - - const { handleSubmit, register, reset, formState } = useForm({ - resolver: zodResolver(validationSchema), - }); - - const userInfo = api.auth.me.useQuery(undefined, { - refetchOnWindowFocus: false, - onSuccess(result) { - reset({ name: result.name }); - }, - }); - - const nameMutatation = api.auth.changeName.useMutation({ - onSuccess(result) { - toast({ - description: result.message, - status: "success", - duration: 6000, - position: "top-right", - isClosable: true, - }); - - Router.push("/"); - }, - - onError(result) { - toast({ - description: result.message, - status: "error", - duration: 6000, - position: "top-right", - isClosable: true, - }); - }, - }); - - const onSubmit = (data: FormValues) => { - if (data.name === userInfo.data?.name) - return toast({ - description: - "Nama yang ingin di ubah tidak boleh sama dengan nama yang sebelumnya!", - status: "error", - duration: 6000, - position: "top-right", - isClosable: true, - }); - - nameMutatation.mutate(data); - }; - - return ( - <> - - Ubah Nama - - - - - Ubah Nama - - - - - -
- - Nama Lengkap - - - {formState.errors?.name?.message} - - - - - - Kembali - - -
-
-
-
-
- - ); -}; - -export default Sidebar(UbahNama); diff --git a/apps/web/src/pages/ubah/password.tsx b/apps/web/src/pages/ubah/password.tsx deleted file mode 100644 index f51a39e9..00000000 --- a/apps/web/src/pages/ubah/password.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import Head from "next/head"; -import NextLink from "next/link"; -import Router from "next/router"; -import { - Box, - Button, - FormControl, - // Form - FormErrorMessage, - FormLabel, - HStack, - Input, - Link, - Text, - VStack, - useColorModeValue, - useToast, -} from "@chakra-ui/react"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; - -import { - ClientChangePasswordSchemaValidator as validationSchema, - type ClientChangePasswordType as FormValues, -} from "@sora/schema-config/auth.schema"; - -import { api } from "~/utils/api"; -import Sidebar from "~/components/Sidebar"; - -const UbahPassword = () => { - const toast = useToast(); - - const { handleSubmit, register, formState, reset } = useForm({ - resolver: zodResolver(validationSchema), - }); - - const passwordMutation = api.auth.changePassword.useMutation({ - onSuccess(result) { - toast({ - description: result.message, - status: "success", - duration: 6000, - position: "top-right", - isClosable: true, - }); - - Router.push("/"); - }, - - onError(result) { - reset(); - - toast({ - description: result.message, - status: "error", - duration: 6000, - position: "top-right", - isClosable: true, - }); - }, - }); - - const onSubmit = (data: FormValues) => - passwordMutation.mutate({ lama: data.lama, baru: data.baru }); - - return ( - <> - - Ubah Password - - - - - Ubah Password - - - - - -
- - Password lama - - - {formState.errors?.lama?.message} - - - - - Password baru - - - - {formState.errors?.baru?.message} - - - - - - Konfirmasi password baru - - - - {formState.errors?.konfirmasi?.message} - - - - - - - Kembali - - -
-
-
-
-
- - ); -}; - -export default Sidebar(UbahPassword); diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css deleted file mode 100644 index b5c61c95..00000000 --- a/apps/web/src/styles/globals.css +++ /dev/null @@ -1,3 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; diff --git a/apps/web/src/utils/api.ts b/apps/web/src/utils/api.ts deleted file mode 100644 index 51231b0d..00000000 --- a/apps/web/src/utils/api.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { httpBatchLink, loggerLink } from "@trpc/client"; -import { createTRPCNext } from "@trpc/next"; -import superjson from "superjson"; - -import type { AppRouter } from "@sora/api"; - -const getBaseUrl = () => { - if (typeof window !== "undefined") return ""; // browser should use relative url - if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; // SSR should use vercel url - - return `http://localhost:3000`; // dev SSR should use localhost -}; - -export const api = createTRPCNext({ - config() { - return { - transformer: superjson, - links: [ - loggerLink({ - enabled: (opts) => - process.env.NODE_ENV === "development" || - (opts.direction === "down" && opts.result instanceof Error), - }), - httpBatchLink({ - url: `${getBaseUrl()}/api/trpc`, - }), - ], - }; - }, - ssr: false, -}); - -export { type RouterInputs, type RouterOutputs } from "@sora/api"; diff --git a/apps/web/src/utils/cors.ts b/apps/web/src/utils/cors.ts deleted file mode 100644 index 109addd8..00000000 --- a/apps/web/src/utils/cors.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ - -import type { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; -import Cors from "cors"; - -const cors = Cors(); - -function runMiddleware(req: NextApiRequest, res: NextApiResponse, fn: any) { - return new Promise((resolve, reject) => { - fn(req, res, (result: any) => { - if (result instanceof Error) { - return reject(result); - } - - return resolve(result); - }); - }); -} - -export function withCors(handler: NextApiHandler) { - return async (req: NextApiRequest, res: NextApiResponse) => { - await runMiddleware(req, res, cors); - - return await handler(req, res); - }; -} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json deleted file mode 100644 index 1527b8ef..00000000 --- a/apps/web/tsconfig.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "baseUrl": ".", - "paths": { - "~/*": ["./src/*"] - }, - "plugins": [{ "name": "next" }] - }, - "exclude": [], - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - "**/*.cjs", - "**/*.mjs", - ".next/types/**/*.ts" - ] -} diff --git a/ecosystem.config.js b/ecosystem.config.js deleted file mode 100644 index 055bb0c6..00000000 --- a/ecosystem.config.js +++ /dev/null @@ -1,36 +0,0 @@ -const path = require("path"); -const fs = require("fs"); -const dotenv = require("dotenv"); - -const envFile = fs.readFileSync(path.join(__dirname, ".env")); -const actualEnv = dotenv.parse(envFile); - -const root = path.join(__dirname); - -const nodeModulesDir = path.join(root, "node_modules"); -const appsDir = path.join(root, "apps"); -const packagesDir = path.join(root, "packages"); - -module.exports = { - apps: [ - { - name: "web", - script: path.join(nodeModulesDir, "next/dist/bin/next"), - cwd: path.join(appsDir, "web"), - args: "start -p 3000", - env: { - NODE_ENV: "production", - ...actualEnv, - }, - }, - { - name: "processor", - script: path.join(appsDir, "processor/dist/index.js"), - cwd: path.join(packagesDir, "db"), - env: { - NODE_ENV: "production", - ...actualEnv, - }, - }, - ], -}; diff --git a/package.json b/package.json index 9a8864d9..1a0c5c38 100644 --- a/package.json +++ b/package.json @@ -1,54 +1,37 @@ { - "name": "sora-root", + "name": "sora-baseline", "private": true, "engines": { - "node": ">=v18.16.0" + "node": ">=20.12.0" }, "scripts": { - "build": "turbo run build --filter=@sora/web --filter=@sora/processor", + "build": "turbo build", "clean": "git clean -xdf node_modules", "clean:workspaces": "turbo clean", - "db:generate": "turbo db:generate", - "db:push": "turbo db:push db:generate", - "dev:web": "turbo run dev --scope=@sora/web", - "dev:chooser": "turbo run dev --scope=sora-chooser-desktop", - "dev:attendance": "turbo run dev --scope=sora-attendance-desktop", - "dev:processor": "turbo run dev --scope=@sora/processor", - "build-desktop:win": "turbo run build-win --filter 'sora-*-desktop'", - "build-desktop:linux": "turbo run build-linux --filter 'sora-*-desktop'", - "format": "prettier --write \"**/*.{js,cjs,mjs,ts,tsx,md,json,yml}\" --ignore-path .gitignore", - "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,css,md}\" --ignore-path .gitignore", - "lint": "turbo lint && manypkg check", - "lint:fix": "turbo lint:fix && manypkg fix", - "type-check": "turbo type-check", - "e2e": "turbo run e2e --scope=@sora/web", - "e2e:ui": "turbo run e2e:ui --scope=@sora/web", - "e2e:db-push": "turbo run e2e:db-push --scope=@sora/db", - "e2e:report": "turbo run e2e:report --scope=@sora/web" + "db:push": "yarn workspace @sora-vp/db push", + "db:studio": "yarn workspace @sora-vp/db studio", + "dev": "turbo dev --parallel", + "format": "turbo format --continue -- --cache --cache-location node_modules/.cache/.prettiercache", + "format:fix": "turbo format --continue -- --write --cache --cache-location node_modules/.cache/.prettiercache", + "lint": "turbo lint --continue -- --cache --cache-location node_modules/.cache/.eslintcache", + "lint:fix": "turbo lint --continue -- --fix --cache --cache-location node_modules/.cache/.eslintcache", + "lint:ws": "yarn dlx sherif@latest", + "postinstall": "yarn lint:ws", + "typecheck": "turbo typecheck", + "ui-add": "yarn workspace @sora-vp/ui ui-add" }, - "dependencies": { - "@ianvs/prettier-plugin-sort-imports": "^3.7.2", - "@manypkg/cli": "^0.21.0", - "@sora/eslint-config": "^0.1.0", - "@types/prettier": "^2.7.2", - "dotenv": "^16.1.3", - "eslint": "^8.40.0", - "prettier": "^2.8.8", - "turbo": "^1.10.12", - "typescript": "^5.2.2" + "devDependencies": { + "@sora-vp/prettier-config": "*", + "@turbo/gen": "^1.13.3", + "prettier": "^3.2.5", + "turbo": "^1.13.3", + "typescript": "^5.4.5" }, - "packageManager": "yarn@3.5.0", + "prettier": "@sora-vp/prettier-config", + "packageManager": "yarn@4.2.2", "workspaces": [ - "apps/web", - "apps/processor", - "apps/desktop/*", - "packages/api", - "packages/auth", - "packages/db", - "packages/config/*", - "packages/id-generator", - "packages/petuah", - "packages/settings", - "packages/ui" + "apps/*", + "packages/*", + "tooling/*" ] } diff --git a/packages/api/env.mjs b/packages/api/env.mjs deleted file mode 100644 index 0780b9b4..00000000 --- a/packages/api/env.mjs +++ /dev/null @@ -1,15 +0,0 @@ -import { createEnv } from "@t3-oss/env-core"; -import { z } from "zod"; - -export const env = createEnv({ - clientPrefix: "", - client: {}, - - server: { - AMQP_URL: z.string().url(), - }, - runtimeEnvStrict: { - AMQP_URL: process.env.AMQP_URL, - }, - skipValidation: !!process.env.CI || !!process.env.SKIP_ENV_VALIDATION, -}); diff --git a/packages/api/eslint.config.js b/packages/api/eslint.config.js new file mode 100644 index 00000000..5e819f71 --- /dev/null +++ b/packages/api/eslint.config.js @@ -0,0 +1,9 @@ +import baseConfig from "@sora-vp/eslint-config/base"; + +/** @type {import('typescript-eslint').Config} */ +export default [ + { + ignores: ["dist/**"], + }, + ...baseConfig, +]; diff --git a/packages/api/index.ts b/packages/api/index.ts deleted file mode 100644 index bd0140c9..00000000 --- a/packages/api/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server"; - -import { type AppRouter } from "./src/root"; - -export { appRouter, type AppRouter } from "./src/root"; -export { createTRPCContext } from "./src/trpc"; - -/** - * Inference helpers for input types - * @example type HelloInput = RouterInputs['example']['hello'] - **/ -export type RouterInputs = inferRouterInputs; - -/** - * Inference helpers for output types - * @example type HelloOutput = RouterOutputs['example']['hello'] - **/ -export type RouterOutputs = inferRouterOutputs; diff --git a/packages/api/package.json b/packages/api/package.json index 42272c11..67527a1d 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,33 +1,38 @@ { - "name": "@sora/api", + "name": "@sora-vp/api", "version": "0.1.0", - "main": "./index.ts", - "types": "./index.ts", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./src/index.ts" + } + }, "license": "MIT", "scripts": { + "build": "tsc", + "dev": "tsc --watch", "clean": "rm -rf .turbo node_modules", - "lint": "eslint .", - "lint:fix": "yarn lint --fix", - "type-check": "tsc --noEmit" + "format": "prettier --check . --ignore-path ../../.gitignore", + "lint": "eslint", + "typecheck": "tsc --noEmit --emitDeclarationOnly false" }, "dependencies": { - "@sora/auth": "^0.1.0", - "@sora/db": "^0.1.0", - "@sora/id-generator": "^0.1.0", - "@sora/settings": "0.1.0", - "@trpc/client": "^10.38.1", - "@trpc/server": "^10.38.1", - "amqplib": "^0.10.3", - "bcrypt": "^5.1.0", - "superjson": "1.13.1", - "zod": "^3.22.2" + "@sora-vp/auth": "*", + "@sora-vp/db": "*", + "@sora-vp/validators": "*", + "@trpc/server": "11.0.0-rc.364", + "superjson": "2.2.1", + "zod": "^3.23.6" }, "devDependencies": { - "@sora/eslint-config": "^0.1.0", - "@types/amqplib": "^0.10.1", - "@types/bcrypt": "^5.0.0", - "@types/node": "^20.2.5", - "eslint": "^8.40.0", - "typescript": "^5.2.2" - } + "@sora-vp/eslint-config": "*", + "@sora-vp/prettier-config": "*", + "@sora-vp/tsconfig": "*", + "eslint": "^9.2.0", + "prettier": "^3.2.5", + "typescript": "^5.4.5" + }, + "prettier": "@sora-vp/prettier-config" } diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts new file mode 100644 index 00000000..1cbe6fdd --- /dev/null +++ b/packages/api/src/index.ts @@ -0,0 +1,33 @@ +import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; + +import type { AppRouter } from "./root"; +import { appRouter } from "./root"; +import { createCallerFactory, createTRPCContext } from "./trpc"; + +/** + * Create a server-side caller for the tRPC API + * @example + * const trpc = createCaller(createContext); + * const res = await trpc.post.all(); + * ^? Post[] + */ +const createCaller = createCallerFactory(appRouter); + +/** + * Inference helpers for input types + * @example + * type PostByIdInput = RouterInputs['post']['byId'] + * ^? { id: number } + **/ +type RouterInputs = inferRouterInputs; + +/** + * Inference helpers for output types + * @example + * type AllPostsOutput = RouterOutputs['post']['all'] + * ^? Post[] + **/ +type RouterOutputs = inferRouterOutputs; + +export { createTRPCContext, appRouter, createCaller }; +export type { AppRouter, RouterInputs, RouterOutputs }; diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index edf8c6e5..730251c7 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -1,14 +1,10 @@ import { authRouter } from "./router/auth"; -import { candidateRouter } from "./router/candidate"; -import { participantRouter } from "./router/participant"; -import { settingsRouter } from "./router/settings"; +import { postRouter } from "./router/post"; import { createTRPCRouter } from "./trpc"; export const appRouter = createTRPCRouter({ auth: authRouter, - candidate: candidateRouter, - settings: settingsRouter, - participant: participantRouter, + post: postRouter, }); // export type definition of API diff --git a/packages/api/src/router/auth.ts b/packages/api/src/router/auth.ts index 5eb0db97..230c0885 100644 --- a/packages/api/src/router/auth.ts +++ b/packages/api/src/router/auth.ts @@ -1,138 +1,12 @@ -import { TRPCError } from "@trpc/server"; -import bcrypt from "bcrypt"; +import type { TRPCRouterRecord } from "@trpc/server"; -import { prisma } from "@sora/db"; -import { - ChangeNameSchemaValidator, - ServerChangePasswordSchemaValidator, - ServerRegisterSchemaValidator, -} from "@sora/schema-config/auth.schema"; +import { protectedProcedure, publicProcedure } from "../trpc"; -import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; - -export const authRouter = createTRPCRouter({ - me: protectedProcedure.query(async ({ ctx }) => { - const user = await prisma.user.findUnique({ - where: { email: ctx.session.user?.email as string }, - }); - if (!user) - throw new TRPCError({ - code: "NOT_FOUND", - message: "Pengguna tidak dapat ditemukan!", - }); - return { - name: user.name, - email: user.email, - createdAt: user.createdAt, - }; +export const authRouter = { + getSession: publicProcedure.query(({ ctx }) => { + return ctx.session; }), - - register: publicProcedure - .input(ServerRegisterSchemaValidator) - .mutation(async ({ input }) => { - const isAlreadyExist = await prisma.user.findUnique({ - where: { email: input.email }, - }); - - if (isAlreadyExist) - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Pengguna dengan email yang sama sudah terdaftar!", - }); - - const salt = bcrypt.genSaltSync(10); - const hash = bcrypt.hashSync(input.password, salt); - - await prisma.user.create({ - data: { - email: input.email, - name: input.name, - password: hash, - }, - }); - - return { - success: true, - }; - }), - - changePassword: protectedProcedure - .input(ServerChangePasswordSchemaValidator) - .mutation(async ({ ctx, input }) => { - const user = await prisma.user.findUnique({ - where: { - email: ctx.session.user?.email as string, - }, - }); - - if (!user) - throw new TRPCError({ - code: "NOT_FOUND", - message: "Pengguna tidak dapat ditemukan!", - }); - - const isCurrentPasswordValid = await bcrypt.compare( - input.lama, - user.password, - ); - - if (!isCurrentPasswordValid) - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Kata sandi yang dimasukkan tidak sesuai!", - }); - - const salt = bcrypt.genSaltSync(10); - const hash = bcrypt.hashSync(input.baru, salt); - - await prisma.user.update({ - where: { - id: user.id, - }, - data: { - password: hash, - }, - }); - - return { - message: "Berhasil mengubah kata sandi!", - }; - }), - - changeName: protectedProcedure - .input(ChangeNameSchemaValidator) - .mutation(async ({ ctx, input }) => { - const user = await prisma.user.findUnique({ - where: { - email: ctx.session.user?.email as string, - }, - }); - - if (!user) - throw new TRPCError({ - code: "NOT_FOUND", - message: "Pengguna tidak dapat ditemukan!", - }); - - const currentNameSameAsNewName = user.name === input.name; - - if (currentNameSameAsNewName) - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Nama yang baru tidak boleh sama dengan yang lama!", - }); - - await prisma.user.update({ - where: { - id: user.id, - }, - data: { - name: input.name, - }, - }); - - return { - message: "Berhasil mengubah nama pengguna!", - }; - }), -}); + getSecretMessage: protectedProcedure.query(() => { + return "you can see this secret message!"; + }), +} satisfies TRPCRouterRecord; diff --git a/packages/api/src/router/candidate.ts b/packages/api/src/router/candidate.ts deleted file mode 100644 index e4d93fcb..00000000 --- a/packages/api/src/router/candidate.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { TRPCError } from "@trpc/server"; -import amqp from "amqplib"; - -import { prisma } from "@sora/db"; -import { - adminDeleteCandidateValidationSchema, - adminGetSpecificCandidateValidationSchema, - upvoteValidationSchema, -} from "@sora/schema-config/admin.candidate.schema"; -import { canVoteNow } from "@sora/settings"; - -import { env } from "../../env.mjs"; -import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; - -const QUEUE_NAME = "vote_queue"; - -export const candidateRouter = createTRPCRouter({ - candidateList: publicProcedure.query(() => - prisma.candidate.findMany({ - select: { - id: true, - name: true, - img: true, - }, - }), - ), - - statisticList: protectedProcedure.query(() => - prisma.candidate.findMany({ select: { name: true, counter: true } }), - ), - - adminCandidateList: protectedProcedure.query(() => - prisma.candidate.findMany(), - ), - - getCandidateAndParticipantCount: protectedProcedure.query(async () => { - const candidates = await prisma.candidate.findMany({ - select: { - counter: true, - }, - where: { - counter: { - gt: 0, - }, - }, - }); - - const alreadyAttendedAndChoosing = await prisma.participant.count({ - where: { - alreadyAttended: true, - alreadyChoosing: true, - }, - }); - - if (!candidates || candidates.length < 1) - throw new TRPCError({ - code: "NOT_FOUND", - message: "Belum ada kandidat yang terdaftar", - }); - - const candidatesAccumulation = candidates - .map(({ counter }) => counter) - .reduce((curr, acc) => curr + acc); - - return { - isMatch: candidatesAccumulation === alreadyAttendedAndChoosing, - participants: alreadyAttendedAndChoosing, - candidates: candidatesAccumulation, - }; - }), - - getSpecificCandidate: protectedProcedure - .input(adminGetSpecificCandidateValidationSchema) - .query(async ({ input }) => { - const kandidat = await prisma.candidate.findUnique({ - where: { id: input.id }, - }); - - if (!kandidat) - throw new TRPCError({ - code: "NOT_FOUND", - message: "Kandidat tidak dapat ditemukan!", - }); - - // For the easiest reset for frontend - return { - kandidat: kandidat.name, - }; - }), - - adminDeleteCandidate: protectedProcedure - .input(adminDeleteCandidateValidationSchema) - .mutation(async ({ input }) => { - const inVotingCondition = canVoteNow(); - - if (inVotingCondition) - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Tidak bisa menghapus kandidat dalam kondisi pemilihan!", - }); - - const candidate = await prisma.candidate.findUnique({ - where: { id: input.id }, - }); - - if (!candidate) - throw new TRPCError({ - code: "NOT_FOUND", - message: "Kandidat tidak dapat ditemukan!", - }); - - if (candidate.counter > 0) - throw new TRPCError({ - code: "BAD_REQUEST", - message: - "Tidak bisa menghapus kandidat dikarenakan sudah ada yang memilihnya!", - }); - - await prisma.candidate.delete({ where: { id: input.id } }); - - return { message: "Berhasil menghapus kandidat!" }; - }), - - upvote: publicProcedure - .input(upvoteValidationSchema) - .mutation(async ({ input }) => { - try { - const connection = await amqp.connect(env.AMQP_URL); - - try { - const messageFromQueue: { success: boolean; message?: string } = - await new Promise(async (resolve, reject) => { - const channel = await connection.createChannel(); - - const { queue } = await channel.assertQueue(QUEUE_NAME, { - durable: true, - }); - - const payload = JSON.stringify(input); - - const response = await channel.assertQueue(""); - const correlationId = response.queue; - - const timeout = setTimeout(async () => { - await channel.deleteQueue(QUEUE_NAME); - await channel.close(); - - reject( - new Error("Timeout: No response received from consumer."), - ); - }, 30_000); - - await channel.consume( - correlationId, - (msg) => { - if (!msg) { - reject( - "Publisher has been cancelled or channel has been closed.", - ); - return; - } - - if (msg.properties.correlationId === correlationId) { - clearTimeout(timeout); - - resolve(JSON.parse(msg.content.toString())); - channel.ack(msg); - } - }, - { noAck: true }, - ); - - channel.sendToQueue(queue, Buffer.from(payload), { - correlationId, - replyTo: correlationId, - }); - }); - - if (!messageFromQueue.success) - throw new TRPCError({ - code: "BAD_REQUEST", - message: messageFromQueue.message as string, - }); - - return { message: "Berhasil memilih kandidat!" }; - } finally { - void connection.close(); - } - } catch (e) { - console.error(e); - - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: - "Gagal memproses pemilihan, mohon hubungi panitia dan coba lagi nanti.", - }); - } - }), -}); diff --git a/packages/api/src/router/participant.ts b/packages/api/src/router/participant.ts deleted file mode 100644 index 494583d4..00000000 --- a/packages/api/src/router/participant.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { TRPCError } from "@trpc/server"; - -import { prisma, type Participant } from "@sora/db"; -import { nanoid } from "@sora/id-generator"; -import { - DeletePesertaValidationSchema, - PaginatedParticipantValidationSchema, - ParticipantAttendValidationSchema, - ParticipantBySubpartValidationSchema, - TambahPesertaManyValidationSchema, - TambahPesertaValidationSchema, - UpdateParticipantValidationSchema, -} from "@sora/schema-config/admin.participant.schema"; -import { canAttendNow } from "@sora/settings"; - -import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; - -export const participantRouter = createTRPCRouter({ - getParticipantPaginated: protectedProcedure - .input(PaginatedParticipantValidationSchema) - .query(async ({ input: { pageSize: limit, pageIndex: offset } }) => { - const participants = await prisma.participant.findMany({ - skip: offset, - take: limit, - }); - - const totalParticipants = await prisma.participant.count(); - const pageCount = Math.ceil(totalParticipants / limit); - const currentPage = Math.ceil(offset / limit) + 1; - - return { - participants, - pageCount, - currentPage, - }; - }), - - getSpecificParticipant: protectedProcedure - .input(ParticipantAttendValidationSchema) - .query(async ({ input: qrId }) => { - const participant = await prisma.participant.findUnique({ - where: { qrId }, - select: { name: true, qrId: true, subpart: true }, - }); - - if (!participant) - throw new TRPCError({ - code: "NOT_FOUND", - message: "Peserta pemilihan tidak dapat ditemukan!", - }); - - return participant; - }), - - updateParticipant: protectedProcedure - .input(UpdateParticipantValidationSchema) - .mutation(async ({ input }) => { - const participant = await prisma.participant.findUnique({ - where: { qrId: input.qrId }, - }); - - if (!participant) - throw new TRPCError({ - code: "NOT_FOUND", - message: "Peserta pemilihan tidak dapat ditemukan!", - }); - - await prisma.participant.update({ - where: { - qrId: input.qrId, - }, - data: { - name: input.name, - subpart: input.subpart, - }, - }); - - return { message: "Berhasil memperbarui informasi peserta!" }; - }), - - createNewParticipant: protectedProcedure - .input(TambahPesertaValidationSchema) - .mutation(async ({ input }) => { - await prisma.participant.create({ - data: { - name: input.name, - subpart: input.subpart, - qrId: nanoid(), - }, - }); - - return { message: "Berhasil menambahkan peserta baru!" }; - }), - - insertManyParticipant: protectedProcedure - .input(TambahPesertaManyValidationSchema) - .mutation(async ({ input }) => { - const okToInsert = input.map((data) => ({ - name: data.Nama, - subpart: data["Bagian Dari"], - qrId: nanoid(), - })); - - const checkThing = await Promise.all( - okToInsert.map(({ name }) => - prisma.participant.findFirst({ where: { name } }), - ), - ); - - if (checkThing.every((data) => data !== null)) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Semua data yang ingin di upload sudah terdaftar!", - }); - } - - if (checkThing.some((data) => data !== null)) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Beberapa data yang ingin di upload sudah ada!", - }); - } - - await prisma.participant.createMany({ - data: okToInsert, - }); - - return { message: "Berhasil mengupload data file csv!" }; - }), - - deleteParticipant: protectedProcedure - .input(DeletePesertaValidationSchema) - .mutation(async ({ input }) => { - if (canAttendNow()) - throw new TRPCError({ - code: "UNAUTHORIZED", - message: - "Tidak di izinkan untuk menghapus peserta karena masih dalam masa diperbolehkan absen!", - }); - - const participant = await prisma.participant.findUnique({ - where: { id: input.id }, - }); - - if (!participant) - throw new TRPCError({ - code: "NOT_FOUND", - message: "Peserta pemilihan tidak dapat ditemukan!", - }); - - if (participant.alreadyAttended) - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Peserta pemilihan sebelumnya sudah absen!", - }); - - if (participant.alreadyChoosing) - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Peserta pemilihan sebelumnya sudah memilih!", - }); - - await prisma.participant.delete({ where: { id: input.id } }); - - return { message: "Berhasil menghapus peserta!" }; - }), - - subparts: protectedProcedure.query(async () => { - const participants = await prisma.participant.findMany({ - select: { subpart: true }, - }); - - if (!participants) - throw new TRPCError({ - code: "NOT_FOUND", - message: "Data peserta pemilihan masih kosong!", - }); - - const subparts = [...new Set(participants.map(({ subpart }) => subpart))]; - - return { subparts }; - }), - - getParticipantBySubpart: protectedProcedure - .input(ParticipantBySubpartValidationSchema) - .query(async ({ input }) => { - if (input.subpart === "") return { participants: [] }; - - const participants = await prisma.participant.findMany({ - where: { - subpart: input.subpart, - }, - }); - - return { participants }; - }), - - participantAttend: publicProcedure - .input(ParticipantAttendValidationSchema) - .mutation(async ({ input }) => { - const participantCanAttend = canAttendNow(); - - if (!participantCanAttend) - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Belum diperbolehkan untuk melakukan absensi!", - }); - - await prisma.$transaction(async (tx) => { - const _participant = await tx.$queryRaw< - Participant[] - >`SELECT * FROM Participant WHERE qrId = ${input} FOR UPDATE`; - const participant = _participant[0]; - - if (!participant) - throw new TRPCError({ - code: "NOT_FOUND", - message: "Peserta pemilihan tidak dapat ditemukan!", - }); - - if (participant.alreadyAttended) - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Kamu sudah absen!", - }); - - await tx.participant.update({ - where: { qrId: input }, - data: { - alreadyAttended: true, - attendedAt: new Date(), - }, - }); - }); - - return { message: "Berhasil melakukan absensi!" }; - }), - - isParticipantAlreadyAttended: publicProcedure - .input(ParticipantAttendValidationSchema) - .mutation(async ({ input }) => { - const participant = await prisma.participant.findUnique({ - where: { qrId: input }, - }); - - if (!participant) - throw new TRPCError({ - code: "NOT_FOUND", - message: "Peserta pemilihan tidak dapat ditemukan!", - }); - - if (participant.alreadyChoosing) - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Kamu sudah memilih kandidat!", - }); - - if (!participant.alreadyAttended) - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Kamu belum absen!", - }); - - return { success: true }; - }), - - exportJsonData: protectedProcedure.query(async () => { - const participants = await prisma.participant.findMany(); - - if (!participants) - throw new TRPCError({ - code: "NOT_FOUND", - message: "Tidak ada data peserta pemilihan!", - }); - - const remapped = participants.map((participant) => ({ - name: participant.name, - qrId: participant.qrId, - subpart: participant.subpart, - })); - - return { data: JSON.stringify(remapped, null, 2) }; - }), - - exportJsonAsXlsxData: protectedProcedure.query(async () => { - const participants = await prisma.participant.findMany(); - - if (!participants) - throw new TRPCError({ - code: "NOT_FOUND", - message: "Tidak ada data peserta pemilihan!", - }); - - return participants.map(({ name, qrId, subpart }) => ({ - name, - qrId, - subpart, - })); - }), - - getParticipantStatus: publicProcedure - .input(ParticipantAttendValidationSchema) - .query(async ({ input }) => { - const participant = await prisma.participant.findUnique({ - where: { qrId: input }, - }); - - if (!participant) - throw new TRPCError({ - code: "NOT_FOUND", - message: "Peserta pemilihan tidak dapat ditemukan!", - }); - - return { - alreadyAttended: participant.alreadyAttended, - alreadyChoosing: participant.alreadyChoosing, - }; - }), -}); diff --git a/packages/api/src/router/post.ts b/packages/api/src/router/post.ts new file mode 100644 index 00000000..8ed6f8e0 --- /dev/null +++ b/packages/api/src/router/post.ts @@ -0,0 +1,39 @@ +import type { TRPCRouterRecord } from "@trpc/server"; +import { desc, eq, schema } from "@sora-vp/db"; +import { CreatePostSchema } from "@sora-vp/validators"; +import { z } from "zod"; + +import { protectedProcedure, publicProcedure } from "../trpc"; + +export const postRouter = { + all: publicProcedure.query(({ ctx }) => { + // return ctx.db.select().from(schema.post).orderBy(desc(schema.post.id)); + return ctx.db.query.post.findMany({ + orderBy: desc(schema.post.id), + limit: 10, + }); + }), + + byId: publicProcedure + .input(z.object({ id: z.number() })) + .query(({ ctx, input }) => { + // return ctx.db + // .select() + // .from(schema.post) + // .where(eq(schema.post.id, input.id)); + + return ctx.db.query.post.findFirst({ + where: eq(schema.post.id, input.id), + }); + }), + + create: protectedProcedure + .input(CreatePostSchema) + .mutation(({ ctx, input }) => { + return ctx.db.insert(schema.post).values(input); + }), + + delete: protectedProcedure.input(z.number()).mutation(({ ctx, input }) => { + return ctx.db.delete(schema.post).where(eq(schema.post.id, input)); + }), +} satisfies TRPCRouterRecord; diff --git a/packages/api/src/router/settings.ts b/packages/api/src/router/settings.ts deleted file mode 100644 index a7ff184c..00000000 --- a/packages/api/src/router/settings.ts +++ /dev/null @@ -1,30 +0,0 @@ -// import settings, { type DataModel } from "~/utils/settings"; -import { - PengaturanPerilakuValidationSchema, - ServerPengaturanWaktuValidationSchema, -} from "@sora/schema-config/admin.settings.schema"; -import settings from "@sora/settings"; - -import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; - -export const settingsRouter = createTRPCRouter({ - getSettings: publicProcedure.query(() => settings.getSettings()), - - changeVotingBehaviour: protectedProcedure - .input(PengaturanPerilakuValidationSchema) - .mutation(({ input }) => { - settings.updateSettings.canVote(input.canVote); - settings.updateSettings.canAttend(input.canAttend); - - return { message: "Pengaturan perilaku pemilihan berhasil diperbarui!" }; - }), - - changeVotingTime: protectedProcedure - .input(ServerPengaturanWaktuValidationSchema) - .mutation(({ input }) => { - settings.updateSettings.startTime(input.startTime); - settings.updateSettings.endTime(input.endTime); - - return { message: "Pengaturan waktu pemilihan berhasil diperbarui!" }; - }), -}); diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index f0f23396..6c54d47f 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -6,57 +6,37 @@ * tl;dr - this is where all the tRPC server stuff is created and plugged in. * The pieces you will need to use are documented accordingly near the end */ -import { TRPCError, initTRPC } from "@trpc/server"; -import { type CreateNextContextOptions } from "@trpc/server/adapters/next"; +import type { Session } from "@sora-vp/auth"; +import { db } from "@sora-vp/db"; +import { initTRPC, TRPCError } from "@trpc/server"; import superjson from "superjson"; import { ZodError } from "zod"; -import { getServerSession, type Session } from "@sora/auth"; -import { prisma } from "@sora/db"; - /** * 1. CONTEXT * - * This section defines the "contexts" that are available in the backend API + * This section defines the "contexts" that are available in the backend API. * - * These allow you to access things like the database, the session, etc, when - * processing a request + * These allow you to access things when processing a request, like the database, the session, etc. * - */ -type CreateContextOptions = { - session: Session | null; -}; - -/** - * This helper generates the "internals" for a tRPC context. If you need to use - * it, you can export it from here + * This helper generates the "internals" for a tRPC context. The API handler and RSC clients each + * wrap this and provides the required context. * - * Examples of things you may need it for: - * - testing, so we dont have to mock Next.js' req/res - * - trpc's `createSSGHelpers` where we don't have req/res - * @see https://create.t3.gg/en/usage/trpc#-servertrpccontextts + * @see https://trpc.io/docs/server/context */ -const createInnerTRPCContext = (opts: CreateContextOptions) => { - return { - session: opts.session, - prisma, - }; -}; - -/** - * This is the actual context you'll use in your router. It will be used to - * process every request that goes through your tRPC endpoint - * @link https://trpc.io/docs/context - */ -export const createTRPCContext = async (opts: CreateNextContextOptions) => { - const { req, res } = opts; +export const createTRPCContext = (opts: { + headers: Headers; + session: Session | null; +}) => { + const session = opts.session; + const source = opts.headers.get("x-trpc-source") ?? "unknown"; - // Get the session from the server using the unstable_getServerSession wrapper function - const session = await getServerSession({ req, res }); + console.log(">>> tRPC Request from", source, "by", session?.user); - return createInnerTRPCContext({ + return { session, - }); + db, + }; }; /** @@ -67,18 +47,21 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => { */ const t = initTRPC.context().create({ transformer: superjson, - errorFormatter({ shape, error }) { - return { - ...shape, - data: { - ...shape.data, - zodError: - error.cause instanceof ZodError ? error.cause.flatten() : null, - }, - }; - }, + errorFormatter: ({ shape, error }) => ({ + ...shape, + data: { + ...shape.data, + zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, + }, + }), }); +/** + * Create a server-side caller + * @see https://trpc.io/docs/server/server-side-calls + */ +export const createCallerFactory = t.createCallerFactory; + /** * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT) * @@ -102,10 +85,14 @@ export const createTRPCRouter = t.router; export const publicProcedure = t.procedure; /** - * Reusable middleware that enforces users are logged in before running the - * procedure + * Protected (authenticated) procedure + * + * If you want a query or mutation to ONLY be accessible to logged in users, use this. It verifies + * the session is valid and guarantees `ctx.session.user` is not null. + * + * @see https://trpc.io/docs/procedures */ -const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { +export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } @@ -116,14 +103,3 @@ const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { }, }); }); - -/** - * Protected (authed) procedure - * - * If you want a query or mutation to ONLY be accessible to logged in users, use - * this. It verifies the session is valid and guarantees ctx.session.user is not - * null - * - * @see https://trpc.io/docs/procedures - */ -export const protectedProcedure = t.procedure.use(enforceUserIsAuthed); diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json index 53584f34..aeb37cba 100644 --- a/packages/api/tsconfig.json +++ b/packages/api/tsconfig.json @@ -1,4 +1,9 @@ { - "extends": "../../tsconfig.json", - "include": ["src", "index.ts", "transformer.ts", "env.mjs"] + "extends": "@sora-vp/tsconfig/internal-package.json", + "compilerOptions": { + "outDir": "dist", + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + }, + "include": ["src"], + "exclude": ["node_modules"] } diff --git a/packages/auth/env.mjs b/packages/auth/env.mjs deleted file mode 100644 index 16b30f43..00000000 --- a/packages/auth/env.mjs +++ /dev/null @@ -1,24 +0,0 @@ -import { createEnv } from "@t3-oss/env-nextjs"; -import { z } from "zod"; - -export const env = createEnv({ - server: { - NEXTAUTH_SECRET: - process.env.NODE_ENV === "production" - ? z.string().min(1) - : z.string().min(1).optional(), - NEXTAUTH_URL: z.preprocess( - // This makes Vercel deployments not fail if you don't set NEXTAUTH_URL - // Since NextAuth.js automatically uses the VERCEL_URL if present. - (str) => process.env.VERCEL_URL ?? str, - // VERCEL_URL doesn't include `https` so it cant be validated as a URL - process.env.VERCEL ? z.string() : z.string().url(), - ), - }, - client: {}, - runtimeEnv: { - NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, - NEXTAUTH_URL: process.env.NEXTAUTH_URL, - }, - skipValidation: !!process.env.CI || !!process.env.SKIP_ENV_VALIDATION, -}); diff --git a/packages/auth/env.ts b/packages/auth/env.ts new file mode 100644 index 00000000..770c20d2 --- /dev/null +++ b/packages/auth/env.ts @@ -0,0 +1,17 @@ +/* eslint-disable no-restricted-properties */ +import { createEnv } from "@t3-oss/env-nextjs"; +import { z } from "zod"; + +export const env = createEnv({ + server: { + AUTH_DISCORD_ID: z.string().min(1), + AUTH_DISCORD_SECRET: z.string().min(1), + AUTH_SECRET: + process.env.NODE_ENV === "production" + ? z.string().min(1) + : z.string().min(1).optional(), + }, + client: {}, + experimental__runtimeEnv: {}, + skipValidation: !!process.env.CI || !!process.env.SKIP_ENV_VALIDATION, +}); diff --git a/packages/auth/eslint.config.js b/packages/auth/eslint.config.js new file mode 100644 index 00000000..7c5f7ee0 --- /dev/null +++ b/packages/auth/eslint.config.js @@ -0,0 +1,10 @@ +import baseConfig, { restrictEnvAccess } from "@sora-vp/eslint-config/base"; + +/** @type {import('typescript-eslint').Config} */ +export default [ + { + ignores: [], + }, + ...baseConfig, + ...restrictEnvAccess, +]; diff --git a/packages/auth/index.ts b/packages/auth/index.ts deleted file mode 100644 index ffbc71fb..00000000 --- a/packages/auth/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { authOptions } from "./src/auth-options"; -export { getServerSession } from "./src/get-session"; -export type { Session } from "next-auth"; diff --git a/packages/auth/package.json b/packages/auth/package.json index d30e89f4..0e52bbdc 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -1,27 +1,39 @@ { - "name": "@sora/auth", + "name": "@sora-vp/auth", "version": "0.1.0", - "main": "./index.ts", - "types": "./index.ts", + "private": true, + "type": "module", + "exports": { + ".": { + "react-server": "./src/index.rsc.ts", + "default": "./src/index.ts" + }, + "./env": "./env.ts" + }, "license": "MIT", "scripts": { "clean": "rm -rf .turbo node_modules", - "lint": "eslint .", - "lint:fix": "pnpm lint --fix", - "type-check": "tsc --noEmit" + "format": "prettier --check . --ignore-path ../../.gitignore", + "lint": "eslint", + "typecheck": "tsc --noEmit" }, "dependencies": { - "@sora/db": "^0.1.0", - "@t3-oss/env-nextjs": "^0.3.1", - "next": "13.4.7", - "next-auth": "^4.22.1", - "react": "18.2.0", - "react-dom": "18.2.0", - "zod": "^3.22.2" + "@auth/drizzle-adapter": "^1.0.1", + "@sora-vp/db": "*", + "@t3-oss/env-nextjs": "^0.10.1", + "next": "^14.2.3", + "next-auth": "5.0.0-beta.17", + "react": "18.3.1", + "react-dom": "18.3.1", + "zod": "^3.23.6" }, "devDependencies": { - "@sora/eslint-config": "^0.1.0", - "eslint": "^8.40.0", - "typescript": "^5.2.2" - } + "@sora-vp/eslint-config": "*", + "@sora-vp/prettier-config": "*", + "@sora-vp/tsconfig": "*", + "eslint": "^9.2.0", + "prettier": "^3.2.5", + "typescript": "^5.4.5" + }, + "prettier": "@sora-vp/prettier-config" } diff --git a/packages/auth/src/auth-options.ts b/packages/auth/src/auth-options.ts deleted file mode 100644 index 70642b22..00000000 --- a/packages/auth/src/auth-options.ts +++ /dev/null @@ -1,69 +0,0 @@ -import bcrypt from "bcrypt"; -import { type DefaultSession, type NextAuthOptions } from "next-auth"; -import CredentialsProvider from "next-auth/providers/credentials"; - -import { prisma } from "@sora/db"; - -/** - * Module augmentation for `next-auth` types - * Allows us to add custom properties to the `session` object - * and keep type safety - * @see https://next-auth.js.org/getting-started/typescript#module-augmentation - **/ -declare module "next-auth" { - interface Session extends DefaultSession { - user: { - id: string; - // ...other properties - // role: UserRole; - } & DefaultSession["user"]; - } - - // interface User { - // // ...other properties - // // role: UserRole; - // } -} - -/** - * Options for NextAuth.js used to configure - * adapters, providers, callbacks, etc. - * @see https://next-auth.js.org/configuration/options - **/ -export const authOptions: NextAuthOptions = { - pages: { - signIn: "/login", - }, - providers: [ - CredentialsProvider({ - name: "credentials", - credentials: { - email: { label: "Email", type: "text" }, - password: { label: "Password", type: "password" }, - }, - async authorize(credentials) { - if (!credentials?.email || !credentials?.password) - throw new Error("Dibutuhkan email dan password!"); - - const user = await prisma.user.findUnique({ - where: { email: credentials?.email }, - }); - - if (!user) throw new Error("Pengguna tidak ditemukan!"); - - const isValidPassword = await bcrypt.compare( - credentials?.password, - user.password, - ); - - if (!isValidPassword) throw new Error("Kata sandi salah!"); - - return { - id: user.id, - name: user.name, - email: user.email, - }; - }, - }), - ], -}; diff --git a/packages/auth/src/config.ts b/packages/auth/src/config.ts new file mode 100644 index 00000000..59a2c4c6 --- /dev/null +++ b/packages/auth/src/config.ts @@ -0,0 +1,35 @@ +import type { DefaultSession, NextAuthConfig } from "next-auth"; +import { DrizzleAdapter } from "@auth/drizzle-adapter"; +import { db, schema } from "@sora-vp/db"; +import Discord from "next-auth/providers/discord"; + +declare module "next-auth" { + interface Session { + user: { + id: string; + } & DefaultSession["user"]; + } +} + +export const authConfig = { + adapter: DrizzleAdapter(db, { + usersTable: schema.users, + accountsTable: schema.accounts, + sessionsTable: schema.sessions, + verificationTokensTable: schema.verificationTokens, + }), + providers: [Discord], + callbacks: { + session: (opts) => { + if (!("user" in opts)) throw "unreachable with session strategy"; + + return { + ...opts.session, + user: { + ...opts.session.user, + id: opts.user.id, + }, + }; + }, + }, +} satisfies NextAuthConfig; diff --git a/packages/auth/src/get-session.ts b/packages/auth/src/get-session.ts deleted file mode 100644 index 482a0e7a..00000000 --- a/packages/auth/src/get-session.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { - GetServerSidePropsContext, - NextApiRequest, - NextApiResponse, -} from "next"; -import { getServerSession as $getServerSession } from "next-auth"; - -import { authOptions } from "./auth-options"; - -type GetServerSessionContext = - | { - req: GetServerSidePropsContext["req"]; - res: GetServerSidePropsContext["res"]; - } - | { req: NextApiRequest; res: NextApiResponse }; -export const getServerSession = (ctx: GetServerSessionContext) => { - return $getServerSession(ctx.req, ctx.res, authOptions); -}; diff --git a/packages/auth/src/index.rsc.ts b/packages/auth/src/index.rsc.ts new file mode 100644 index 00000000..4094ae6d --- /dev/null +++ b/packages/auth/src/index.rsc.ts @@ -0,0 +1,21 @@ +import { cache } from "react"; +import NextAuth from "next-auth"; + +import { authConfig } from "./config"; + +export type { Session } from "next-auth"; + +const { + handlers: { GET, POST }, + auth: defaultAuth, + signIn, + signOut, +} = NextAuth(authConfig); + +/** + * This is the main way to get session data for your RSCs. + * This will de-duplicate all calls to next-auth's default `auth()` function and only call it once per request + */ +const auth = cache(defaultAuth); + +export { GET, POST, auth, signIn, signOut }; diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts new file mode 100644 index 00000000..47df4567 --- /dev/null +++ b/packages/auth/src/index.ts @@ -0,0 +1,14 @@ +import NextAuth from "next-auth"; + +import { authConfig } from "./config"; + +export type { Session } from "next-auth"; + +const { + handlers: { GET, POST }, + auth, + signIn, + signOut, +} = NextAuth(authConfig); + +export { GET, POST, auth, signIn, signOut }; diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json index 7126cb9a..67a85f3f 100644 --- a/packages/auth/tsconfig.json +++ b/packages/auth/tsconfig.json @@ -1,4 +1,8 @@ { - "extends": "../../tsconfig.json", - "include": ["src", "*.ts", "env.mjs"] + "extends": "@sora-vp/tsconfig/base.json", + "compilerOptions": { + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + }, + "include": ["src", "*.ts"], + "exclude": ["node_modules"] } diff --git a/packages/db/eslint.config.js b/packages/db/eslint.config.js new file mode 100644 index 00000000..5e819f71 --- /dev/null +++ b/packages/db/eslint.config.js @@ -0,0 +1,9 @@ +import baseConfig from "@sora-vp/eslint-config/base"; + +/** @type {import('typescript-eslint').Config} */ +export default [ + { + ignores: ["dist/**"], + }, + ...baseConfig, +]; diff --git a/packages/db/index.ts b/packages/db/index.ts deleted file mode 100644 index b72182af..00000000 --- a/packages/db/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { PrismaClient } from "@prisma/client"; - -export * from "@prisma/client"; - -const globalForPrisma = globalThis as { prisma?: PrismaClient }; - -export const prisma = - globalForPrisma.prisma || - new PrismaClient({ - log: - process.env.NODE_ENV === "development" - ? ["query", "error", "warn"] - : ["error"], - }); - -if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; diff --git a/packages/db/package.json b/packages/db/package.json index 547d4b74..d6e03692 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,24 +1,42 @@ { - "name": "@sora/db", + "name": "@sora-vp/db", "version": "0.1.0", - "main": "./index.ts", - "types": "./index.ts", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./src/index.ts" + } + }, "license": "MIT", "scripts": { + "build": "tsc", + "dev": "tsc --watch", "clean": "rm -rf .turbo node_modules", - "db:generate": "yarn with-env prisma generate", - "db:push": "yarn with-env prisma db push --skip-generate", - "dev": "yarn with-env prisma studio --port 5556", - "e2e:db-push": "yarn with-env-test prisma db push --skip-generate", - "with-env": "dotenv -e ../../.env --", - "with-env-test": "dotenv -e ../../.env.test --" + "format": "prettier --check . --ignore-path ../../.gitignore", + "lint": "eslint", + "push": "yarn with-env drizzle-kit push:mysql --config src/config.ts", + "studio": "yarn with-env drizzle-kit studio --config src/config.ts", + "typecheck": "tsc --noEmit --emitDeclarationOnly false", + "with-env": "dotenv -e ../../.env --" }, "dependencies": { - "@prisma/client": "^5.1.1" + "@planetscale/database": "^1.18.0", + "@t3-oss/env-core": "^0.10.1", + "drizzle-orm": "^0.30.10", + "zod": "^3.23.6" }, "devDependencies": { - "dotenv-cli": "^7.2.1", - "prisma": "^5.1.1", - "typescript": "^5.2.2" - } + "@sora-vp/eslint-config": "*", + "@sora-vp/prettier-config": "*", + "@sora-vp/tsconfig": "*", + "dotenv-cli": "^7.4.1", + "drizzle-kit": "^0.20.18", + "eslint": "^9.2.0", + "mysql2": "^3.9.7", + "prettier": "^3.2.5", + "typescript": "^5.4.5" + }, + "prettier": "@sora-vp/prettier-config" } diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma deleted file mode 100644 index 0953db1a..00000000 --- a/packages/db/prisma/schema.prisma +++ /dev/null @@ -1,38 +0,0 @@ -// This is your Prisma schema file, -// learn more about it in the docs: https://pris.ly/d/prisma-schema - -generator client { - provider = "prisma-client-js" -} - -datasource db { - provider = "mysql" - url = env("DATABASE_URL") -} - -model User { - id String @id @default(cuid()) - name String @db.VarChar(50) - email String @unique - password String - createdAt DateTime @default(now()) -} - -model Candidate { - id Int @id @default(autoincrement()) - counter Int @default(0) - name String @unique - img String @unique @db.VarChar(50) -} - -model Participant { - id Int @id @default(autoincrement()) - name String @db.VarChar(255) - subpart String @db.VarChar(50) - qrId String @unique @db.VarChar(30) - alreadyAttended Boolean @default(false) - attendedAt DateTime? - alreadyChoosing Boolean @default(false) - choosingAt DateTime? -} - diff --git a/packages/db/src/config.ts b/packages/db/src/config.ts new file mode 100644 index 00000000..8477b819 --- /dev/null +++ b/packages/db/src/config.ts @@ -0,0 +1,27 @@ +import type { Config } from "drizzle-kit"; +import { createEnv } from "@t3-oss/env-core"; +import * as z from "zod"; + +const env = createEnv({ + server: { + DB_HOST: z.string(), + DB_NAME: z.string(), + DB_USERNAME: z.string(), + DB_PASSWORD: z.string(), + }, + runtimeEnv: process.env, + emptyStringAsUndefined: true, +}); + +// Push requires SSL so use URL instead of username/password +export const connectionStr = new URL(`mysql://${env.DB_HOST}/${env.DB_NAME}`); +connectionStr.username = env.DB_USERNAME; +connectionStr.password = env.DB_PASSWORD; +connectionStr.searchParams.set("ssl", '{"rejectUnauthorized":true}'); + +export default { + schema: "./src/schema", + driver: "mysql2", + dbCredentials: { uri: connectionStr.href }, + tablesFilter: ["t3turbo_*"], +} satisfies Config; diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts new file mode 100644 index 00000000..aa506c89 --- /dev/null +++ b/packages/db/src/index.ts @@ -0,0 +1,14 @@ +import { Client } from "@planetscale/database"; +import { drizzle } from "drizzle-orm/planetscale-serverless"; + +import { connectionStr } from "./config"; +import * as auth from "./schema/auth"; +import * as post from "./schema/post"; + +export * from "drizzle-orm/sql"; +export { alias } from "drizzle-orm/mysql-core"; + +export const schema = { ...auth, ...post }; + +const psClient = new Client({ url: connectionStr.href }); +export const db = drizzle(psClient, { schema }); diff --git a/packages/db/src/schema/_table.ts b/packages/db/src/schema/_table.ts new file mode 100644 index 00000000..122c188c --- /dev/null +++ b/packages/db/src/schema/_table.ts @@ -0,0 +1,9 @@ +import { mysqlTableCreator } from "drizzle-orm/mysql-core"; + +/** + * This is an example of how to use the multi-project schema feature of Drizzle ORM. + * Use the same database instance for multiple projects. + * + * @see https://orm.drizzle.team/docs/goodies#multi-project-schema + */ +export const mySqlTable = mysqlTableCreator((name) => `t3turbo_${name}`); diff --git a/packages/db/src/schema/auth.ts b/packages/db/src/schema/auth.ts new file mode 100644 index 00000000..3991c6ce --- /dev/null +++ b/packages/db/src/schema/auth.ts @@ -0,0 +1,85 @@ +import { relations, sql } from "drizzle-orm"; +import { + index, + int, + primaryKey, + text, + timestamp, + varchar, +} from "drizzle-orm/mysql-core"; + +import { mySqlTable } from "./_table"; + +export const users = mySqlTable("user", { + id: varchar("id", { length: 255 }).notNull().primaryKey(), + name: varchar("name", { length: 255 }), + email: varchar("email", { length: 255 }).notNull(), + emailVerified: timestamp("emailVerified", { + mode: "date", + fsp: 3, + }).default(sql`CURRENT_TIMESTAMP(3)`), + image: varchar("image", { length: 255 }), +}); + +export const usersRelations = relations(users, ({ many }) => ({ + accounts: many(accounts), +})); + +export const accounts = mySqlTable( + "account", + { + userId: varchar("userId", { length: 255 }).notNull(), + type: varchar("type", { length: 255 }) + .$type<"oauth" | "oidc" | "email">() + .notNull(), + provider: varchar("provider", { length: 255 }).notNull(), + providerAccountId: varchar("providerAccountId", { length: 255 }).notNull(), + refresh_token: varchar("refresh_token", { length: 255 }), + access_token: text("access_token"), + expires_at: int("expires_at"), + token_type: varchar("token_type", { length: 255 }), + scope: varchar("scope", { length: 255 }), + id_token: text("id_token"), + session_state: varchar("session_state", { length: 255 }), + }, + (account) => ({ + compoundKey: primaryKey({ + columns: [account.provider, account.providerAccountId], + }), + userIdIdx: index("userId_idx").on(account.userId), + }), +); + +export const accountsRelations = relations(accounts, ({ one }) => ({ + user: one(users, { fields: [accounts.userId], references: [users.id] }), +})); + +export const sessions = mySqlTable( + "session", + { + sessionToken: varchar("sessionToken", { length: 255 }) + .notNull() + .primaryKey(), + userId: varchar("userId", { length: 255 }).notNull(), + expires: timestamp("expires", { mode: "date" }).notNull(), + }, + (session) => ({ + userIdIdx: index("userId_idx").on(session.userId), + }), +); + +export const sessionsRelations = relations(sessions, ({ one }) => ({ + user: one(users, { fields: [sessions.userId], references: [users.id] }), +})); + +export const verificationTokens = mySqlTable( + "verificationToken", + { + identifier: varchar("identifier", { length: 255 }).notNull(), + token: varchar("token", { length: 255 }).notNull(), + expires: timestamp("expires", { mode: "date" }).notNull(), + }, + (vt) => ({ + compoundKey: primaryKey({ columns: [vt.identifier, vt.token] }), + }), +); diff --git a/packages/db/src/schema/post.ts b/packages/db/src/schema/post.ts new file mode 100644 index 00000000..3b26be3a --- /dev/null +++ b/packages/db/src/schema/post.ts @@ -0,0 +1,14 @@ +import { sql } from "drizzle-orm"; +import { serial, timestamp, varchar } from "drizzle-orm/mysql-core"; + +import { mySqlTable } from "./_table"; + +export const post = mySqlTable("post", { + id: serial("id").primaryKey(), + title: varchar("name", { length: 256 }).notNull(), + content: varchar("content", { length: 256 }).notNull(), + createdAt: timestamp("created_at") + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + updatedAt: timestamp("updatedAt").onUpdateNow(), +}); diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json index 4fe49052..aeb37cba 100644 --- a/packages/db/tsconfig.json +++ b/packages/db/tsconfig.json @@ -1,4 +1,9 @@ { - "extends": "../../tsconfig.json", - "include": ["index.ts"] + "extends": "@sora-vp/tsconfig/internal-package.json", + "compilerOptions": { + "outDir": "dist", + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + }, + "include": ["src"], + "exclude": ["node_modules"] } diff --git a/packages/settings/package.json b/packages/settings/package.json deleted file mode 100644 index ffe30b12..00000000 --- a/packages/settings/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@sora/settings", - "version": "0.1.0", - "private": true, - "types": "src/index.ts", - "main": "src/index.ts", - "author": "Ezra Khairan Permana", - "license": "GPL-3.0", - "dependencies": { - "@types/eslint": "^8.37.0", - "@typescript-eslint/eslint-plugin": "^5.59.2", - "@typescript-eslint/parser": "^5.59.2", - "eslint-config-next": "^13.4.2", - "eslint-config-prettier": "^8.8.0", - "eslint-config-turbo": "^1.9.8", - "eslint-plugin-react": "7.32.2", - "luxon": "^3.4.3" - }, - "devDependencies": { - "@types/luxon": "^3.3.2", - "eslint": "^8.40.0", - "typescript": "^5.2.2" - } -} diff --git a/packages/settings/src/SettingsManager.ts b/packages/settings/src/SettingsManager.ts deleted file mode 100644 index c16d7a10..00000000 --- a/packages/settings/src/SettingsManager.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { EventEmitter } from "events"; - -interface AppSettings { - startTime: Date | null; - endTime: Date | null; - canVote: boolean | null; - canAttend: boolean | null; -} - -interface ReturnedValues { - startTime: Date | null; - endTime: Date | null; - canVote: boolean; - canAttend: boolean; -} - -type UpdateEventMap = { - update: ReturnedValues; -}; - -type ExtractValues = T extends unknown ? T[keyof T] : never; - -export class SettingsManager extends EventEmitter { - private settingsMap: Map>; - - constructor() { - super(); - this.settingsMap = new Map>(); - } - - getSettings(): ReturnedValues { - type DateOrUndef = Date | undefined; - type BoolOrUndef = boolean | undefined; - - const startTime = this.settingsMap.get("startTime") as DateOrUndef; - const endTime = this.settingsMap.get("endTime") as DateOrUndef; - - const canVote = this.settingsMap.get("canVote") as BoolOrUndef; - const canAttend = this.settingsMap.get("canAttend") as BoolOrUndef; - - return { - startTime: startTime ?? null, - endTime: endTime ?? null, - canVote: canVote ?? false, - canAttend: canAttend ?? false, - }; - } - - private updateBuilder( - key: keyof AppSettings, - value: ExtractValues, - ): void { - this.settingsMap.set(key, value); - this.emit("update", this.getSettings()); - } - - updateSettings = { - startTime: (time: Date) => this.updateBuilder("startTime", time), - endTime: (time: Date) => this.updateBuilder("endTime", time), - canVote: (votable: boolean) => this.updateBuilder("canVote", votable), - canAttend: (attendable: boolean) => - this.updateBuilder("canAttend", attendable), - } as const; - - on( - event: K, - listener: (payload: UpdateEventMap[K]) => void, - ): this { - return super.on(event, listener); - } -} - -export const settings = new SettingsManager(); diff --git a/packages/settings/src/index.ts b/packages/settings/src/index.ts deleted file mode 100644 index d64d1c79..00000000 --- a/packages/settings/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { settings } from "./SettingsManager"; - -export default settings; -export * from "./utils"; diff --git a/packages/settings/src/utils.ts b/packages/settings/src/utils.ts deleted file mode 100644 index 229f6b8f..00000000 --- a/packages/settings/src/utils.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { DateTime } from "luxon"; - -import { settings as settingsLib } from "./SettingsManager"; - -const getTimePermission = () => { - const settings = settingsLib.getSettings(); - - const currentTime = DateTime.now().toUTC().toJSDate().getTime(); - - const timeConfig = { - mulai: settings?.startTime - ? DateTime.fromJSDate(settings.startTime, { - zone: "utc", - }) - .toJSDate() - .getTime() - : false, - selesai: settings?.endTime - ? DateTime.fromJSDate(settings.endTime, { - zone: "utc", - }) - .toJSDate() - .getTime() - : false, - }; - - return { - isPermittedByTime: - // Start - (timeConfig.mulai - ? (timeConfig.mulai as number) <= currentTime - : false) && - // End - (timeConfig.selesai - ? (timeConfig.selesai as number) >= currentTime - : false), - settings, - }; -}; - -export const canVoteNow = () => { - const { isPermittedByTime, settings } = getTimePermission(); - - return isPermittedByTime && settings.canVote; -}; - -export const canAttendNow = () => { - const { isPermittedByTime, settings } = getTimePermission(); - - return isPermittedByTime && settings.canAttend; -}; diff --git a/packages/settings/tsconfig.json b/packages/settings/tsconfig.json deleted file mode 100644 index 55d75511..00000000 --- a/packages/settings/tsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "include": ["src", "*.ts"] -} diff --git a/packages/ui/.gitignore b/packages/ui/.gitignore deleted file mode 100644 index b512c09d..00000000 --- a/packages/ui/.gitignore +++ /dev/null @@ -1 +0,0 @@ -node_modules \ No newline at end of file diff --git a/packages/ui/Loading/index.tsx b/packages/ui/Loading/index.tsx deleted file mode 100644 index 35c54500..00000000 --- a/packages/ui/Loading/index.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { useMemo } from "react"; -import { Box, Flex, HStack, Heading, Spinner, Text } from "@chakra-ui/react"; - -import wiseWord from "@sora/petuah/petuah.json"; - -export const Loading = ({ headingText }: { headingText: string }) => { - const masterOogway = useMemo(() => { - const randomIndex = new Uint8Array(1); - crypto.getRandomValues(randomIndex); - - const index = Math.floor(Math.random() % wiseWord.length); - - return wiseWord[index]; - }, []); - - return ( - - - - - - {headingText} - - - - - {masterOogway} - - - - ); -}; diff --git a/packages/ui/Setting/index.tsx b/packages/ui/Setting/index.tsx deleted file mode 100644 index 9882f413..00000000 --- a/packages/ui/Setting/index.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -import { - Box, - Button, - FormControl, - FormErrorMessage, - FormHelperText, - FormLabel, - HStack, - Heading, - Input, - useToast, -} from "@chakra-ui/react"; -// eslint-disable-next-line @typescript-eslint/consistent-type-imports -import { electronAPI } from "@electron-toolkit/preload"; - -type Props = Pick; - -export const Setting = ({ ipcRenderer }: Props) => { - const toast = useToast(); - - const [formURL, setFormURL] = useState(""); - - const setServerUrl = useCallback(async (url: string) => { - try { - const serverURL = new URL(url); - - await ipcRenderer.invoke("set-server-url", serverURL.origin); - - toast({ - description: "Berhasil memperbarui pengaturan alamat server!", - status: "success", - duration: 4500, - position: "top-right", - }); - - setTimeout(() => { - location.href = "#/"; - location.reload(); - }, 3000); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (error: any) { - toast({ - description: `Gagal memperbarui url | ${error.message}`, - status: "error", - duration: 5000, - position: "top-right", - }); - } - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - const composeAsync = async () => { - const storeValue = await ipcRenderer.invoke("get-server-url"); - - setFormURL(storeValue); - }; - - composeAsync(); - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return ( - - - - Pengaturan Aplikasi -
{ - e.preventDefault(); - - setServerUrl(formURL); - }} - > - - Alamat Server Utama - setFormURL(e.target.value)} - /> - {formURL !== "" ? ( - - Masukan alamat server utama aplikasi sora supaya aplikasi ini - bisa berjalan. - - ) : ( - Diperlukan alamat server. - )} - - - -
-
-
-
- ); -}; diff --git a/packages/ui/Sidebar/LogoutButton.tsx b/packages/ui/Sidebar/LogoutButton.tsx deleted file mode 100644 index 9cd50291..00000000 --- a/packages/ui/Sidebar/LogoutButton.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { useRef, useState } from "react"; -import Router from "next/router"; -import { - AlertDialog, - AlertDialogBody, - AlertDialogContent, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogOverlay, - Button, - Flex, - Icon, - useDisclosure, - useToast, -} from "@chakra-ui/react"; -import { signOut } from "next-auth/react"; -import { BsDoorOpen } from "react-icons/bs"; - -const LogoutButton = () => { - const { isOpen, onOpen, onClose } = useDisclosure(); - const toast = useToast(); - - const [isLoading, setLoading] = useState(false); - - const cancelRef = useRef(null!); - const buttonElement = useRef(null!); - - return ( - <> - - - { - if (!isLoading) onClose(); - }} - isCentered - > - - - - Konfirmasi Logout - - - - Apakah anda yakin untuk Logout? Anda masih bisa login kembali. - - - - - - - - - - - ); -}; - -export default LogoutButton; diff --git a/packages/ui/Sidebar/ModeToggler.tsx b/packages/ui/Sidebar/ModeToggler.tsx deleted file mode 100644 index 91155bc9..00000000 --- a/packages/ui/Sidebar/ModeToggler.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Button, Flex, Icon, useColorMode } from "@chakra-ui/react"; -import { BsMoonFill, BsSun } from "react-icons/bs"; - -const ModeToggler = () => { - const { colorMode, toggleColorMode } = useColorMode(); - - return ( - - - - ); -}; - -export default ModeToggler; diff --git a/packages/ui/Sidebar/Sidebar.tsx b/packages/ui/Sidebar/Sidebar.tsx deleted file mode 100644 index 5c936a5a..00000000 --- a/packages/ui/Sidebar/Sidebar.tsx +++ /dev/null @@ -1,188 +0,0 @@ -/* eslint-disable react-hooks/rules-of-hooks */ - -import { memo, useRef, type ElementType, type ReactNode } from "react"; -import localFont from "next/font/local"; -import NextLink from "next/link"; -import { - Box, - CloseButton, - Drawer, - DrawerContent, - Flex, - Icon, - IconButton, - Link, - Text, - useColorModeValue, - useDisclosure, - type BoxProps, - type FlexProps, -} from "@chakra-ui/react"; -import { type IconType } from "react-icons"; -import { FiMenu } from "react-icons/fi"; - -import LogoutButton from "./LogoutButton"; -import ModeToggler from "./ModeToggler"; - -interface LinkItemProps { - name: string; - icon: IconType; - href: string; -} - -const sundaneseFont = localFont({ - src: "./fonts/NotoSansSundanese-Regular.ttf", -}); - -type SimpleSidebarType = JSX.IntrinsicAttributes; - -export const SidebarWrapper = - (LinkItems: Array) => (WrappedComponent: ElementType) => - // eslint-disable-next-line react/display-name - memo((props: SimpleSidebarType) => { - const { isOpen, onOpen, onClose } = useDisclosure(); - const container = useRef(null!); - - return ( - - onClose} - display={{ base: "none", md: "block" }} - /> - - - - - - {/* mobilenav */} - - - - - - ); - }); - -interface SidebarProps extends BoxProps { - onClose: () => void; - LinkItems: Array; -} - -const SidebarContent = ({ LinkItems, onClose, ...rest }: SidebarProps) => { - return ( - - - - ᮞᮧᮛ - - - - {LinkItems.map((link) => ( - - {link.name} - - ))} - - - - ); -}; - -interface NavItemProps extends FlexProps { - icon: IconType; - children: ReactNode; - href: string; -} -const NavItem = ({ icon, children, href, ...rest }: NavItemProps) => { - return ( - - - - {icon && ( - - )} - {children} - - - - ); -}; - -interface MobileProps extends FlexProps { - onOpen: () => void; -} -const MobileNav = ({ onOpen, ...rest }: MobileProps) => { - return ( - - } - /> - - - ᮞᮧᮛ - - - ); -}; diff --git a/packages/ui/Sidebar/fonts/NotoSansSundanese-Regular.ttf b/packages/ui/Sidebar/fonts/NotoSansSundanese-Regular.ttf deleted file mode 100644 index fc6c702728de4778ba3b0f8b950b72184e3e16d6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 39620 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 HNP@osEp^Zi diff --git a/packages/ui/Sidebar/index.tsx b/packages/ui/Sidebar/index.tsx deleted file mode 100644 index fffefa17..00000000 --- a/packages/ui/Sidebar/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { SidebarWrapper } from "./Sidebar"; diff --git a/packages/ui/components.json b/packages/ui/components.json new file mode 100644 index 00000000..47679cbb --- /dev/null +++ b/packages/ui/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "./tailwind.config.ts", + "css": "unused.css", + "baseColor": "zinc", + "cssVariables": true + }, + "aliases": { + "utils": "@sora-vp/ui", + "components": "src/", + "ui": "src/" + } +} diff --git a/packages/ui/eslint.config.js b/packages/ui/eslint.config.js new file mode 100644 index 00000000..1f2fec19 --- /dev/null +++ b/packages/ui/eslint.config.js @@ -0,0 +1,11 @@ +import baseConfig from "@sora-vp/eslint-config/base"; +import reactConfig from "@sora-vp/eslint-config/react"; + +/** @type {import('typescript-eslint').Config} */ +export default [ + { + ignores: [], + }, + ...baseConfig, + ...reactConfig, +]; diff --git a/packages/ui/index.tsx b/packages/ui/index.tsx deleted file mode 100644 index 68a56173..00000000 --- a/packages/ui/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./Sidebar"; -export * from "./Loading"; -export * from "./Setting"; diff --git a/packages/ui/package.json b/packages/ui/package.json index 84a20570..695558f6 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,31 +1,50 @@ { - "name": "@sora/ui", + "name": "@sora-vp/ui", + "private": true, "version": "0.1.0", - "main": "./index.tsx", - "types": "./index.tsx", - "license": "GPL-3.0", - "devDependencies": { - "@electron-toolkit/preload": "^1.0.3", - "@types/eslint": "^8.37.0", - "@types/react": "^18.2.6", - "@types/react-dom": "^18.2.4", - "@typescript-eslint/eslint-plugin": "^5.59.2", - "@typescript-eslint/parser": "^5.59.2", - "eslint-config-next": "^13.4.2", - "eslint-config-prettier": "^8.8.0", - "eslint-config-turbo": "^1.9.8", - "eslint-plugin-react": "7.32.2", - "react": "18.2.0", - "react-dom": "18.2.0", - "typescript": "^5.2.2" + "type": "module", + "exports": { + ".": "./src/index.ts", + "./*": "./src/*.tsx" + }, + "license": "MIT", + "scripts": { + "add": "pnpm dlx shadcn-ui add", + "clean": "rm -rf .turbo node_modules", + "format": "prettier --check . --ignore-path ../../.gitignore", + "lint": "eslint", + "typecheck": "tsc --noEmit --emitDeclarationOnly false", + "ui-add": "pnpm dlx shadcn-ui add && prettier src --write --list-different" }, "dependencies": { - "@chakra-ui/react": "^2.8.1", - "@emotion/react": "^11.11.1", - "@emotion/styled": "^11.11.0", - "framer-motion": "^10.16.0", - "next": "13.4.7", - "next-auth": "^4.22.1", - "react-icons": "^4.8.0" - } + "@hookform/resolvers": "^3.3.4", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-slot": "^1.0.2", + "class-variance-authority": "^0.7.0", + "next-themes": "^0.3.0", + "react-hook-form": "^7.51.4", + "sonner": "^1.4.41", + "tailwind-merge": "^2.3.0", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "@sora-vp/eslint-config": "*", + "@sora-vp/prettier-config": "*", + "@sora-vp/tailwind-config": "*", + "@sora-vp/tsconfig": "*", + "@types/react": "^18.3.1", + "eslint": "^9.2.0", + "prettier": "^3.2.5", + "react": "18.3.1", + "tailwindcss": "^3.4.3", + "typescript": "^5.4.5", + "zod": "^3.23.6" + }, + "peerDependencies": { + "react": "18.3.1", + "zod": "^3.23.6" + }, + "prettier": "@sora-vp/prettier-config" } diff --git a/packages/ui/src/button.tsx b/packages/ui/src/button.tsx new file mode 100644 index 00000000..39990fca --- /dev/null +++ b/packages/ui/src/button.tsx @@ -0,0 +1,58 @@ +import type { VariantProps } from "class-variance-authority"; +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva } from "class-variance-authority"; + +import { cn } from "@sora-vp/ui"; + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + primary: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + sm: "h-8 rounded-md px-3 text-xs", + md: "h-9 px-4 py-2", + lg: "h-10 rounded-md px-8", + icon: "size-9", + }, + }, + defaultVariants: { + variant: "primary", + size: "md", + }, + }, +); + +interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/packages/ui/src/dropdown-menu.tsx b/packages/ui/src/dropdown-menu.tsx new file mode 100644 index 00000000..b29051b3 --- /dev/null +++ b/packages/ui/src/dropdown-menu.tsx @@ -0,0 +1,200 @@ +"use client"; + +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { + CheckIcon, + ChevronRightIcon, + DotFilledIcon, +} from "@radix-ui/react-icons"; + +import { cn } from "@sora-vp/ui"; + +const DropdownMenu = DropdownMenuPrimitive.Root; +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; +const DropdownMenuGroup = DropdownMenuPrimitive.Group; +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; +const DropdownMenuSub = DropdownMenuPrimitive.Sub; +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/packages/ui/src/form.tsx b/packages/ui/src/form.tsx new file mode 100644 index 00000000..53bdd053 --- /dev/null +++ b/packages/ui/src/form.tsx @@ -0,0 +1,201 @@ +"use client"; + +import type * as LabelPrimitive from "@radix-ui/react-label"; +import type { + ControllerProps, + FieldPath, + FieldValues, + UseFormProps, +} from "react-hook-form"; +import type { ZodType, ZodTypeDef } from "zod"; +import * as React from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Slot } from "@radix-ui/react-slot"; +import { + useForm as __useForm, + Controller, + FormProvider, + useFormContext, +} from "react-hook-form"; + +import { cn } from "@sora-vp/ui"; + +import { Label } from "./label"; + +const useForm = ( + props: Omit, "resolver"> & { + schema: ZodType; + }, +) => { + const form = __useForm({ + ...props, + resolver: zodResolver(props.schema, undefined), + }); + + return form; +}; + +const Form = FormProvider; + +interface FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> { + name: TName; +} + +const FormFieldContext = React.createContext( + null, +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + const fieldState = getFieldState(fieldContext.name, formState); + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +interface FormItemContextValue { + id: string; +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue, +); + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + +
+ + ); +}); +FormItem.displayName = "FormItem"; + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return ( +
-
- ); + return <>; } diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts deleted file mode 100644 index 321b4b52..00000000 --- a/apps/web/src/middleware.ts +++ /dev/null @@ -1,11 +0,0 @@ -export { auth as middleware } from "@sora-vp/auth"; - -// Or like this if you need to do something here. -// export default auth((req) => { -// console.log(req.auth) // { session: { user: { ... } } } -// }) - -// Read more: https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher -export const config = { - matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"], -}; diff --git a/packages/auth/env.ts b/packages/auth/env.ts index 770c20d2..8428f956 100644 --- a/packages/auth/env.ts +++ b/packages/auth/env.ts @@ -4,8 +4,6 @@ import { z } from "zod"; export const env = createEnv({ server: { - AUTH_DISCORD_ID: z.string().min(1), - AUTH_DISCORD_SECRET: z.string().min(1), AUTH_SECRET: process.env.NODE_ENV === "production" ? z.string().min(1) diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 054b83cf..68c7b3dd 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -9,6 +9,7 @@ export * from "drizzle-orm/sql"; export { alias } from "drizzle-orm/mysql-core"; const poolConnection = mysql.createPool(connectionStr.toString()); + export const db = drizzle(poolConnection, { schema: mainSchema, mode: "default", From 912cf5726467e20dd1f49eac0320eb9856a14fd4 Mon Sep 17 00:00:00 2001 From: Ezra Khairan Permana Date: Fri, 17 May 2024 21:34:57 +0700 Subject: [PATCH 08/44] feat: menambahkan font sunda --- .../app/fonts/NotoSansSundanese-Regular.ttf | Bin 0 -> 39620 bytes apps/web/src/app/globals.css | 14 +-- apps/web/src/app/layout.tsx | 40 ++++++++- apps/web/src/app/page.tsx | 8 +- package.json | 2 + packages/ui/package.json | 5 +- packages/ui/src/button.tsx | 12 +-- packages/ui/src/card.tsx | 83 ++++++++++++++++++ packages/ui/src/form.tsx | 54 ++++-------- packages/ui/src/resizable.tsx | 45 ++++++++++ packages/ui/src/theme.tsx | 8 +- yarn.lock | 11 +++ 12 files changed, 222 insertions(+), 60 deletions(-) create mode 100644 apps/web/src/app/fonts/NotoSansSundanese-Regular.ttf create mode 100644 packages/ui/src/card.tsx create mode 100644 packages/ui/src/resizable.tsx diff --git a/apps/web/src/app/fonts/NotoSansSundanese-Regular.ttf b/apps/web/src/app/fonts/NotoSansSundanese-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..fc6c702728de4778ba3b0f8b950b72184e3e16d6 GIT binary patch literal 39620 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 HNP@osEp^Zi literal 0 HcmV?d00001 diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index b9d992f8..88e338ee 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -10,19 +10,19 @@ --card-foreground: 240 10% 3.9%; --popover: 0 0% 100%; --popover-foreground: 240 10% 3.9%; - --primary: 327 66% 69%; - --primary-foreground: 337 65.5% 17.1%; + --primary: 240 5.9% 10%; + --primary-foreground: 0 0% 98%; --secondary: 240 4.8% 95.9%; --secondary-foreground: 240 5.9% 10%; --muted: 240 4.8% 95.9%; --muted-foreground: 240 3.8% 46.1%; --accent: 240 4.8% 95.9%; --accent-foreground: 240 5.9% 10%; - --destructive: 0 72.22% 50.59%; + --destructive: 0 84.2% 60.2%; --destructive-foreground: 0 0% 98%; --border: 240 5.9% 90%; --input: 240 5.9% 90%; - --ring: 240 5% 64.9%; + --ring: 240 5.9% 10%; --radius: 0.5rem; } @@ -33,8 +33,8 @@ --card-foreground: 0 0% 98%; --popover: 240 10% 3.9%; --popover-foreground: 0 0% 98%; - --primary: 327 66% 69%; - --primary-foreground: 337 65.5% 17.1%; + --primary: 0 0% 98%; + --primary-foreground: 240 5.9% 10%; --secondary: 240 3.7% 15.9%; --secondary-foreground: 0 0% 98%; --muted: 240 3.7% 15.9%; @@ -42,7 +42,7 @@ --accent: 240 3.7% 15.9%; --accent-foreground: 0 0% 98%; --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 85.7% 97.3%; + --destructive-foreground: 0 0% 98%; --border: 240 3.7% 15.9%; --input: 240 3.7% 15.9%; --ring: 240 4.9% 83.9%; diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 3ea8c04c..e6f69b42 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,8 +1,14 @@ import type { Metadata, Viewport } from "next"; +import localFont from "next/font/local"; import { GeistMono } from "geist/font/mono"; import { GeistSans } from "geist/font/sans"; import { cn } from "@sora-vp/ui"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@sora-vp/ui/resizable"; import { ThemeProvider, ThemeToggle } from "@sora-vp/ui/theme"; import { Toaster } from "@sora-vp/ui/toast"; @@ -24,6 +30,10 @@ export const viewport: Viewport = { ], }; +const sundaneseFont = localFont({ + src: "./fonts/NotoSansSundanese-Regular.ttf", +}); + export default function RootLayout(props: { children: React.ReactNode }) { return ( @@ -35,7 +45,35 @@ export default function RootLayout(props: { children: React.ReactNode }) { )} > - {props.children} + + +
+ ᮞᮧᮛ +
+
+ + + + +
+ Atas +
+
+ +
+ {props.children} +
+
+
+
+
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 3d914f1b..ea9f709b 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,3 +1,9 @@ +import { Button } from "@sora-vp/ui/button"; + export default function HomePage() { - return <>; + return ( + <> + + + ); } diff --git a/package.json b/package.json index 1a0c5c38..4b9670af 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "db:push": "yarn workspace @sora-vp/db push", "db:studio": "yarn workspace @sora-vp/db studio", "dev": "turbo dev --parallel", + "dev:web": "turbo run dev --scope=@sora-vp/web", + "dev:processor": "turbo run dev --scope=@sora-vp/processor", "format": "turbo format --continue -- --cache --cache-location node_modules/.cache/.prettiercache", "format:fix": "turbo format --continue -- --write --cache --cache-location node_modules/.cache/.prettiercache", "lint": "turbo lint --continue -- --cache --cache-location node_modules/.cache/.eslintcache", diff --git a/packages/ui/package.json b/packages/ui/package.json index 695558f6..288a9acc 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -9,12 +9,12 @@ }, "license": "MIT", "scripts": { - "add": "pnpm dlx shadcn-ui add", + "add": "yarn dlx shadcn-ui add", "clean": "rm -rf .turbo node_modules", "format": "prettier --check . --ignore-path ../../.gitignore", "lint": "eslint", "typecheck": "tsc --noEmit --emitDeclarationOnly false", - "ui-add": "pnpm dlx shadcn-ui add && prettier src --write --list-different" + "ui-add": "yarn dlx shadcn-ui add && prettier src --write --list-different" }, "dependencies": { "@hookform/resolvers": "^3.3.4", @@ -25,6 +25,7 @@ "class-variance-authority": "^0.7.0", "next-themes": "^0.3.0", "react-hook-form": "^7.51.4", + "react-resizable-panels": "^2.0.19", "sonner": "^1.4.41", "tailwind-merge": "^2.3.0", "tailwindcss-animate": "^1.0.7" diff --git a/packages/ui/src/button.tsx b/packages/ui/src/button.tsx index 39990fca..a0fce582 100644 --- a/packages/ui/src/button.tsx +++ b/packages/ui/src/button.tsx @@ -10,7 +10,7 @@ const buttonVariants = cva( { variants: { variant: { - primary: + default: "bg-primary text-primary-foreground shadow hover:bg-primary/90", destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", @@ -22,20 +22,20 @@ const buttonVariants = cva( link: "text-primary underline-offset-4 hover:underline", }, size: { + default: "h-9 px-4 py-2", sm: "h-8 rounded-md px-3 text-xs", - md: "h-9 px-4 py-2", lg: "h-10 rounded-md px-8", - icon: "size-9", + icon: "h-9 w-9", }, }, defaultVariants: { - variant: "primary", - size: "md", + variant: "default", + size: "default", }, }, ); -interface ButtonProps +export interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps { asChild?: boolean; diff --git a/packages/ui/src/card.tsx b/packages/ui/src/card.tsx new file mode 100644 index 00000000..ac2315c4 --- /dev/null +++ b/packages/ui/src/card.tsx @@ -0,0 +1,83 @@ +import * as React from "react"; + +import { cn } from "@sora-vp/ui"; + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardDescription.displayName = "CardDescription"; + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardContent.displayName = "CardContent"; + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardFooter.displayName = "CardFooter"; + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, +}; diff --git a/packages/ui/src/form.tsx b/packages/ui/src/form.tsx index 53bdd053..437bfcc1 100644 --- a/packages/ui/src/form.tsx +++ b/packages/ui/src/form.tsx @@ -1,51 +1,29 @@ -"use client"; - -import type * as LabelPrimitive from "@radix-ui/react-label"; -import type { - ControllerProps, - FieldPath, - FieldValues, - UseFormProps, -} from "react-hook-form"; -import type { ZodType, ZodTypeDef } from "zod"; import * as React from "react"; -import { zodResolver } from "@hookform/resolvers/zod"; +import * as LabelPrimitive from "@radix-ui/react-label"; import { Slot } from "@radix-ui/react-slot"; import { - useForm as __useForm, Controller, + ControllerProps, + FieldPath, + FieldValues, FormProvider, useFormContext, } from "react-hook-form"; +import { Label } from "src//label"; import { cn } from "@sora-vp/ui"; -import { Label } from "./label"; - -const useForm = ( - props: Omit, "resolver"> & { - schema: ZodType; - }, -) => { - const form = __useForm({ - ...props, - resolver: zodResolver(props.schema, undefined), - }); - - return form; -}; - const Form = FormProvider; -interface FormFieldContextValue< +type FormFieldContextValue< TFieldValues extends FieldValues = FieldValues, TName extends FieldPath = FieldPath, -> { +> = { name: TName; -} +}; -const FormFieldContext = React.createContext( - null, +const FormFieldContext = React.createContext( + {} as FormFieldContextValue, ); const FormField = < @@ -66,10 +44,11 @@ const useFormField = () => { const itemContext = React.useContext(FormItemContext); const { getFieldState, formState } = useFormContext(); + const fieldState = getFieldState(fieldContext.name, formState); + if (!fieldContext) { throw new Error("useFormField should be used within "); } - const fieldState = getFieldState(fieldContext.name, formState); const { id } = itemContext; @@ -83,9 +62,9 @@ const useFormField = () => { }; }; -interface FormItemContextValue { +type FormItemContextValue = { id: string; -} +}; const FormItemContext = React.createContext( {} as FormItemContextValue, @@ -167,7 +146,7 @@ const FormMessage = React.forwardRef< React.HTMLAttributes >(({ className, children, ...props }, ref) => { const { error, formMessageId } = useFormField(); - const body = error ? String(error.message) : children; + const body = error ? String(error?.message) : children; if (!body) { return null; @@ -187,7 +166,6 @@ const FormMessage = React.forwardRef< FormMessage.displayName = "FormMessage"; export { - useForm, useFormField, Form, FormItem, @@ -197,5 +175,3 @@ export { FormMessage, FormField, }; - -export { useFieldArray } from "react-hook-form"; diff --git a/packages/ui/src/resizable.tsx b/packages/ui/src/resizable.tsx new file mode 100644 index 00000000..7fdad774 --- /dev/null +++ b/packages/ui/src/resizable.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { DragHandleDots2Icon } from "@radix-ui/react-icons"; +import * as ResizablePrimitive from "react-resizable-panels"; + +import { cn } from "@sora-vp/ui"; + +const ResizablePanelGroup = ({ + className, + ...props +}: React.ComponentProps) => ( + +); + +const ResizablePanel = ResizablePrimitive.Panel; + +const ResizableHandle = ({ + withHandle, + className, + ...props +}: React.ComponentProps & { + withHandle?: boolean; +}) => ( + div]:rotate-90", + className, + )} + {...props} + > + {withHandle && ( +
+ +
+ )} +
+); + +export { ResizablePanelGroup, ResizablePanel, ResizableHandle }; diff --git a/packages/ui/src/theme.tsx b/packages/ui/src/theme.tsx index 763130db..789cc0ee 100644 --- a/packages/ui/src/theme.tsx +++ b/packages/ui/src/theme.tsx @@ -21,18 +21,18 @@ function ThemeToggle() { setTheme("light")}> - Light + Terang setTheme("dark")}> - Dark + Gelap setTheme("system")}> - System + Sistem diff --git a/yarn.lock b/yarn.lock index c7b7913c..a9f82142 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2566,6 +2566,7 @@ __metadata: prettier: "npm:^3.2.5" react: "npm:18.3.1" react-hook-form: "npm:^7.51.4" + react-resizable-panels: "npm:^2.0.19" sonner: "npm:^1.4.41" tailwind-merge: "npm:^2.3.0" tailwindcss: "npm:^3.4.3" @@ -9668,6 +9669,16 @@ __metadata: languageName: node linkType: hard +"react-resizable-panels@npm:^2.0.19": + version: 2.0.19 + resolution: "react-resizable-panels@npm:2.0.19" + peerDependencies: + react: ^16.14.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 + checksum: 10c0/eb9cb511aec917895dba842cb933c9885ea510f752b4f3b8c358bf33be8b7b6bf2fc4a81db7a16977e6b09f614a14c6652f15232ff03bce68a8845dcf179abf7 + languageName: node + linkType: hard + "react-style-singleton@npm:^2.2.1": version: 2.2.1 resolution: "react-style-singleton@npm:2.2.1" From 5f39878f8a80b37e9a289cc78a11f2936c3ea822 Mon Sep 17 00:00:00 2001 From: Ezra Khairan Permana Date: Mon, 20 May 2024 08:08:30 +0700 Subject: [PATCH 09/44] feat: menambahkan halaman login dan registrasi (permulaan) --- .env.example | 13 +++--- apps/web/src/app/(auth)/admin/page.tsx | 5 +++ apps/web/src/app/{ => (auth)}/layout.tsx | 12 +++-- apps/web/src/app/(noauth)/(logreg)/layout.tsx | 13 ++++++ .../src/app/(noauth)/(logreg)/login/page.tsx | 3 ++ .../app/(noauth)/(logreg)/register/page.tsx | 3 ++ apps/web/src/app/(noauth)/layout.tsx | 44 +++++++++++++++++++ apps/web/src/app/(noauth)/page.tsx | 26 +++++++++++ apps/web/src/app/page.tsx | 9 ---- 9 files changed, 108 insertions(+), 20 deletions(-) create mode 100644 apps/web/src/app/(auth)/admin/page.tsx rename apps/web/src/app/{ => (auth)}/layout.tsx (87%) create mode 100644 apps/web/src/app/(noauth)/(logreg)/layout.tsx create mode 100644 apps/web/src/app/(noauth)/(logreg)/login/page.tsx create mode 100644 apps/web/src/app/(noauth)/(logreg)/register/page.tsx create mode 100644 apps/web/src/app/(noauth)/layout.tsx create mode 100644 apps/web/src/app/(noauth)/page.tsx delete mode 100644 apps/web/src/app/page.tsx diff --git a/.env.example b/.env.example index a439a5f9..dfe693bf 100644 --- a/.env.example +++ b/.env.example @@ -5,16 +5,13 @@ # If you are cloning this repo, create a copy of this file named `.env` and populate it with your secrets. # The database URL is used to connect to your PlanetScale database. -DB_HOST='aws.connect.psdb.cloud' -DB_NAME='YOUR_DB_NAME' +DB_HOST='localhost' +DB_NAME='sora' DB_USERNAME='' -DB_PASSWORD='pscale_pw_' +DB_PASSWORD='' # You can generate the secret via 'openssl rand -base64 32' on Unix +# or using node js itself to generate the random secret +# node -e 'console.log(require("crypto").randomBytes(50).toString("base64"));' # @see https://next-auth.js.org/configuration/options#secret AUTH_SECRET='supersecret' - -# Preconfigured Discord OAuth provider, works out-of-the-box -# @see https://next-auth.js.org/providers/discord -AUTH_DISCORD_ID='' -AUTH_DISCORD_SECRET='' diff --git a/apps/web/src/app/(auth)/admin/page.tsx b/apps/web/src/app/(auth)/admin/page.tsx new file mode 100644 index 00000000..0d4e1a6e --- /dev/null +++ b/apps/web/src/app/(auth)/admin/page.tsx @@ -0,0 +1,5 @@ +import { Button } from "@sora-vp/ui/button"; + +export default function AdminPage() { + return <>; +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/(auth)/layout.tsx similarity index 87% rename from apps/web/src/app/layout.tsx rename to apps/web/src/app/(auth)/layout.tsx index e6f69b42..2c37c53a 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/(auth)/layout.tsx @@ -1,8 +1,10 @@ import type { Metadata, Viewport } from "next"; import localFont from "next/font/local"; +import { redirect } from "next/navigation"; import { GeistMono } from "geist/font/mono"; import { GeistSans } from "geist/font/sans"; +import { auth } from "@sora-vp/auth"; import { cn } from "@sora-vp/ui"; import { ResizableHandle, @@ -31,10 +33,14 @@ export const viewport: Viewport = { }; const sundaneseFont = localFont({ - src: "./fonts/NotoSansSundanese-Regular.ttf", + src: "../fonts/NotoSansSundanese-Regular.ttf", }); -export default function RootLayout(props: { children: React.ReactNode }) { +export default async function RootLayout(props: { children: React.ReactNode }) { + const isLoggedIn = await auth(); + + if (!isLoggedIn) redirect("/login"); + return (
- ᮞᮧᮛ + ᮞᮧᮛ
diff --git a/apps/web/src/app/(noauth)/(logreg)/layout.tsx b/apps/web/src/app/(noauth)/(logreg)/layout.tsx new file mode 100644 index 00000000..587596e7 --- /dev/null +++ b/apps/web/src/app/(noauth)/(logreg)/layout.tsx @@ -0,0 +1,13 @@ +import { redirect } from "next/navigation"; + +import { auth } from "@sora-vp/auth"; + +export default async function LogRegLayout(props: { + children: React.ReactNode; +}) { + const alreadyLoggedIn = await auth(); + + if (alreadyLoggedIn) redirect("/admin"); + + return <>{props.children}; +} diff --git a/apps/web/src/app/(noauth)/(logreg)/login/page.tsx b/apps/web/src/app/(noauth)/(logreg)/login/page.tsx new file mode 100644 index 00000000..428f4881 --- /dev/null +++ b/apps/web/src/app/(noauth)/(logreg)/login/page.tsx @@ -0,0 +1,3 @@ +export default function LoginPage() { + return <>; +} diff --git a/apps/web/src/app/(noauth)/(logreg)/register/page.tsx b/apps/web/src/app/(noauth)/(logreg)/register/page.tsx new file mode 100644 index 00000000..3873963c --- /dev/null +++ b/apps/web/src/app/(noauth)/(logreg)/register/page.tsx @@ -0,0 +1,3 @@ +export default function RegisterPage() { + return <>; +} diff --git a/apps/web/src/app/(noauth)/layout.tsx b/apps/web/src/app/(noauth)/layout.tsx new file mode 100644 index 00000000..acc412eb --- /dev/null +++ b/apps/web/src/app/(noauth)/layout.tsx @@ -0,0 +1,44 @@ +import type { Metadata, Viewport } from "next"; +import localFont from "next/font/local"; +import { GeistMono } from "geist/font/mono"; +import { GeistSans } from "geist/font/sans"; + +import { cn } from "@sora-vp/ui"; +import { ThemeProvider, ThemeToggle } from "@sora-vp/ui/theme"; +import { Toaster } from "@sora-vp/ui/toast"; + +import "~/app/globals.css"; + +import { env } from "~/env"; + +export const metadata: Metadata = { + title: "sora", +}; + +export const viewport: Viewport = { + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "white" }, + { media: "(prefers-color-scheme: dark)", color: "black" }, + ], +}; + +export default function RootLayout(props: { children: React.ReactNode }) { + return ( + + + + {props.children} +
+ +
+
+ + + ); +} diff --git a/apps/web/src/app/(noauth)/page.tsx b/apps/web/src/app/(noauth)/page.tsx new file mode 100644 index 00000000..0059aa3a --- /dev/null +++ b/apps/web/src/app/(noauth)/page.tsx @@ -0,0 +1,26 @@ +import localFont from "next/font/local"; + +import { cn } from "@sora-vp/ui"; +import { Button } from "@sora-vp/ui/button"; + +const sundaneseFont = localFont({ + src: "../fonts/NotoSansSundanese-Regular.ttf", +}); + +export default function HomePage() { + return ( +
+

+ ᮞᮧᮛ +

+

+ Sebuah aplikasi pemilihan yang membantu proses demokrasi. +

+
+ ); +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx deleted file mode 100644 index ea9f709b..00000000 --- a/apps/web/src/app/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { Button } from "@sora-vp/ui/button"; - -export default function HomePage() { - return ( - <> - - - ); -} From defc2597a4876a30d487be86b55e5dfcea7ad17d Mon Sep 17 00:00:00 2001 From: Ezra Khairan Permana Date: Tue, 21 May 2024 08:28:33 +0700 Subject: [PATCH 10/44] feat: menambahkan fungsionalitas login dan registrasi --- apps/web/src/app/(auth)/layout.tsx | 40 +++- apps/web/src/app/(noauth)/(logreg)/layout.tsx | 13 +- .../src/app/(noauth)/(logreg)/login/page.tsx | 4 +- .../app/(noauth)/(logreg)/register/page.tsx | 4 +- apps/web/src/app/(noauth)/layout.tsx | 3 - .../web/src/app/_components/auth-showcase.tsx | 42 ---- .../src/app/_components/auth/login-page.tsx | 143 +++++++++++++ .../_components/auth/registration-page.tsx | 201 ++++++++++++++++++ apps/web/src/app/_components/posts.tsx | 176 --------------- packages/api/src/root.ts | 2 - packages/api/src/router/auth.ts | 44 +++- packages/auth/src/config.ts | 6 + packages/db/src/index.ts | 13 +- packages/db/src/schema/main.ts | 4 +- packages/ui/src/form.tsx | 3 +- packages/validators/src/auth.ts | 47 ++++ packages/validators/src/index.ts | 7 +- yarn.lock | 6 +- 18 files changed, 510 insertions(+), 248 deletions(-) delete mode 100644 apps/web/src/app/_components/auth-showcase.tsx create mode 100644 apps/web/src/app/_components/auth/login-page.tsx create mode 100644 apps/web/src/app/_components/auth/registration-page.tsx delete mode 100644 apps/web/src/app/_components/posts.tsx create mode 100644 packages/validators/src/auth.ts diff --git a/apps/web/src/app/(auth)/layout.tsx b/apps/web/src/app/(auth)/layout.tsx index 2c37c53a..a07b8aed 100644 --- a/apps/web/src/app/(auth)/layout.tsx +++ b/apps/web/src/app/(auth)/layout.tsx @@ -5,6 +5,7 @@ import { GeistMono } from "geist/font/mono"; import { GeistSans } from "geist/font/sans"; import { auth } from "@sora-vp/auth"; +import { preparedGetUserByEmail } from "@sora-vp/db"; import { cn } from "@sora-vp/ui"; import { ResizableHandle, @@ -18,8 +19,6 @@ import { TRPCReactProvider } from "~/trpc/react"; import "~/app/globals.css"; -import { env } from "~/env"; - export const metadata: Metadata = { title: "sora baseline | aplikasi pemilihan", description: "Aplikasi pemilihan ketua yang baru.", @@ -41,6 +40,41 @@ export default async function RootLayout(props: { children: React.ReactNode }) { if (!isLoggedIn) redirect("/login"); + const currentUser = await preparedGetUserByEmail.execute({ + email: isLoggedIn.user.email, + }); + + if (!currentUser!.verifiedAt) + return ( + + + +
+

+ Anda Belum Terverifikasi +

+

+ Anda belum terverifikasi oleh administrator yang lain, mohon + konfirmasi ke panitia yang lain supaya anda bisa mengakses + dashboard admin. Jika sudah maka refresh halaman ini atau keluar + dan login kembali. +

+
+ +
+ +
+
+ + + ); + return (
- + diff --git a/apps/web/src/app/(noauth)/(logreg)/layout.tsx b/apps/web/src/app/(noauth)/(logreg)/layout.tsx index 587596e7..9b664d59 100644 --- a/apps/web/src/app/(noauth)/(logreg)/layout.tsx +++ b/apps/web/src/app/(noauth)/(logreg)/layout.tsx @@ -1,6 +1,9 @@ import { redirect } from "next/navigation"; import { auth } from "@sora-vp/auth"; +import { Toaster } from "@sora-vp/ui/toast"; + +import { TRPCReactProvider } from "~/trpc/react"; export default async function LogRegLayout(props: { children: React.ReactNode; @@ -9,5 +12,13 @@ export default async function LogRegLayout(props: { if (alreadyLoggedIn) redirect("/admin"); - return <>{props.children}; + return ( + +
+ {props.children} +
+ + +
+ ); } diff --git a/apps/web/src/app/(noauth)/(logreg)/login/page.tsx b/apps/web/src/app/(noauth)/(logreg)/login/page.tsx index 428f4881..e2d137f7 100644 --- a/apps/web/src/app/(noauth)/(logreg)/login/page.tsx +++ b/apps/web/src/app/(noauth)/(logreg)/login/page.tsx @@ -1,3 +1,5 @@ +import { LoginComponent } from "~/app/_components/auth/login-page"; + export default function LoginPage() { - return <>; + return ; } diff --git a/apps/web/src/app/(noauth)/(logreg)/register/page.tsx b/apps/web/src/app/(noauth)/(logreg)/register/page.tsx index 3873963c..9cf5999c 100644 --- a/apps/web/src/app/(noauth)/(logreg)/register/page.tsx +++ b/apps/web/src/app/(noauth)/(logreg)/register/page.tsx @@ -1,3 +1,5 @@ +import { RegistrationComponent } from "~/app/_components/auth/registration-page"; + export default function RegisterPage() { - return <>; + return ; } diff --git a/apps/web/src/app/(noauth)/layout.tsx b/apps/web/src/app/(noauth)/layout.tsx index acc412eb..a657f02a 100644 --- a/apps/web/src/app/(noauth)/layout.tsx +++ b/apps/web/src/app/(noauth)/layout.tsx @@ -5,12 +5,9 @@ import { GeistSans } from "geist/font/sans"; import { cn } from "@sora-vp/ui"; import { ThemeProvider, ThemeToggle } from "@sora-vp/ui/theme"; -import { Toaster } from "@sora-vp/ui/toast"; import "~/app/globals.css"; -import { env } from "~/env"; - export const metadata: Metadata = { title: "sora", }; diff --git a/apps/web/src/app/_components/auth-showcase.tsx b/apps/web/src/app/_components/auth-showcase.tsx deleted file mode 100644 index 4f605fd1..00000000 --- a/apps/web/src/app/_components/auth-showcase.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { auth, signIn, signOut } from "@sora-vp/auth"; -import { Button } from "@sora-vp/ui/button"; - -export async function AuthShowcase() { - const session = await auth(); - - if (!session) { - return ( -
- -
- ); - } - - return ( -
-

- Logged in as {session.user.name} -

- -
- -
-
- ); -} diff --git a/apps/web/src/app/_components/auth/login-page.tsx b/apps/web/src/app/_components/auth/login-page.tsx new file mode 100644 index 00000000..8793eaca --- /dev/null +++ b/apps/web/src/app/_components/auth/login-page.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { signIn } from "next-auth/react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { Button } from "@sora-vp/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@sora-vp/ui/card"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@sora-vp/ui/form"; +import { Input } from "@sora-vp/ui/input"; +import { toast } from "@sora-vp/ui/toast"; +import { auth } from "@sora-vp/validators"; + +type FormSchema = z.infer; + +export function LoginComponent() { + const router = useRouter(); + + const form = useForm({ + resolver: zodResolver(auth.LoginFormSchema), + defaultValues: { + email: "", + password: "", + }, + }); + + async function onSubmit(values: FormSchema) { + const loginResult = await signIn("credentials", { + redirect: false, + ...values, + }); + + if (loginResult.error) { + toast.error("Gagal login", { description: "Email atau password salah!" }); + + return; + } + + toast.success("Berhasil login!"); + + router.replace("/admin"); + } + + return ( + + + Login sebagai Administrator + + Mohon masukan email dan kata sandi. + + + +
+ + ( + + Email + + + + + Masukan email yang sudah di daftarkan sebelumnya. + + + + )} + /> + + ( + + Kata sandi + + + + + Masukan email yang sudah di daftarkan sebelumnya. + + + + )} + /> + +
+ + + {!form.formState.isSubmitting ? ( + + daftarkan akun jika belum ada administrator + + ) : ( +

+ daftarkan akun jika belum ada administrator +

+ )} +
+ + +
+
+ ); +} diff --git a/apps/web/src/app/_components/auth/registration-page.tsx b/apps/web/src/app/_components/auth/registration-page.tsx new file mode 100644 index 00000000..ef904b45 --- /dev/null +++ b/apps/web/src/app/_components/auth/registration-page.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { Button } from "@sora-vp/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@sora-vp/ui/card"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@sora-vp/ui/form"; +import { Input } from "@sora-vp/ui/input"; +import { toast } from "@sora-vp/ui/toast"; +import { auth } from "@sora-vp/validators"; + +import { api } from "~/trpc/react"; + +type FormSchema = z.infer; + +export function RegistrationComponent() { + const router = useRouter(); + + const form = useForm({ + resolver: zodResolver(auth.RegisterFormSchema), + defaultValues: { + email: "", + name: "", + password: "", + passConfirm: "", + }, + }); + + const registerUser = api.auth.register.useMutation({ + onSuccess(data) { + if (data.success) { + toast("Operasi berhasil!", { + description: + "Berhasil mendaftarkan akun baru! Silahkan login terlebih dahulu.", + }); + + form.reset(); + + router.replace("/login"); + } + }, + onError(error) { + toast.error("Gagal mendaftarkan administrator", { + description: error.message, + }); + }, + }); + + return ( + + + Daftarkan Administrator + + Mohon isi informasi akun administrator yang baru. + + + +
+ + registerUser.mutate({ + email: val.email, + password: val.password, + name: val.name, + }), + )} + className="space-y-4" + > + ( + + Email + + + + Email akun administrator. + + + )} + /> + + ( + + Nama Lengkap + + + + + Nama lengkap administrator yang baru. + + + + )} + /> + +
+ ( + + Kata sandi + + + + + Masukan kata sandi yang aman. + + + + )} + /> + + ( + + Konfirmasi kata sandi + + + + + Masukan kata sandi yang sama. + + + + )} + /> +
+ +
+ + + {!registerUser.isPending ? ( + + sudah memiliki akun? login + + ) : ( +

+ sudah memiliki akun? login +

+ )} +
+ + +
+
+ ); +} diff --git a/apps/web/src/app/_components/posts.tsx b/apps/web/src/app/_components/posts.tsx deleted file mode 100644 index 4e409291..00000000 --- a/apps/web/src/app/_components/posts.tsx +++ /dev/null @@ -1,176 +0,0 @@ -"use client"; - -import type { RouterOutputs } from "@sora-vp/api"; -import { use } from "react"; - -import { cn } from "@sora-vp/ui"; -import { Button } from "@sora-vp/ui/button"; -import { - Form, - FormControl, - FormField, - FormItem, - FormMessage, - useForm, -} from "@sora-vp/ui/form"; -import { Input } from "@sora-vp/ui/input"; -import { toast } from "@sora-vp/ui/toast"; -import { CreatePostSchema } from "@sora-vp/validators"; - -import { api } from "~/trpc/react"; - -export function CreatePostForm() { - const form = useForm({ - schema: CreatePostSchema, - defaultValues: { - content: "", - title: "", - }, - }); - - const utils = api.useUtils(); - const createPost = api.post.create.useMutation({ - onSuccess: async () => { - form.reset(); - await utils.post.invalidate(); - }, - onError: (err) => { - toast.error( - err.data?.code === "UNAUTHORIZED" - ? "You must be logged in to post" - : "Failed to create post", - ); - }, - }); - - return ( -
- { - createPost.mutate(data); - })} - > - ( - - - - - - - )} - /> - ( - - - - - - - )} - /> - - - - ); -} - -export function PostList(props: { - posts: Promise; -}) { - // TODO: Make `useSuspenseQuery` work without having to pass a promise from RSC - const initialData = use(props.posts); - const { data: posts } = api.post.all.useQuery(undefined, { - initialData, - }); - - if (posts.length === 0) { - return ( -
- - - - -
-

No posts yet

-
-
- ); - } - - return ( -
- {posts.map((p) => { - return ; - })} -
- ); -} - -export function PostCard(props: { - post: RouterOutputs["post"]["all"][number]; -}) { - const utils = api.useUtils(); - const deletePost = api.post.delete.useMutation({ - onSuccess: async () => { - await utils.post.invalidate(); - }, - onError: (err) => { - toast.error( - err.data?.code === "UNAUTHORIZED" - ? "You must be logged in to delete a post" - : "Failed to delete post", - ); - }, - }); - - return ( -
-
-

{props.post.title}

-

{props.post.content}

-
-
- -
-
- ); -} - -export function PostCardSkeleton(props: { pulse?: boolean }) { - const { pulse = true } = props; - return ( -
-
-

-   -

-

-   -

-
-
- ); -} diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index 730251c7..61b8636c 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -1,10 +1,8 @@ import { authRouter } from "./router/auth"; -import { postRouter } from "./router/post"; import { createTRPCRouter } from "./trpc"; export const appRouter = createTRPCRouter({ auth: authRouter, - post: postRouter, }); // export type definition of API diff --git a/packages/api/src/router/auth.ts b/packages/api/src/router/auth.ts index 230c0885..24a0db14 100644 --- a/packages/api/src/router/auth.ts +++ b/packages/api/src/router/auth.ts @@ -1,11 +1,49 @@ import type { TRPCRouterRecord } from "@trpc/server"; +import { TRPCError } from "@trpc/server"; +import bcrypt from "bcrypt"; + +import { countUserTable, preparedGetUserByEmail, schema } from "@sora-vp/db"; +import { auth as authValidator } from "@sora-vp/validators"; import { protectedProcedure, publicProcedure } from "../trpc"; export const authRouter = { - getSession: publicProcedure.query(({ ctx }) => { - return ctx.session; - }), + register: publicProcedure + .input(authValidator.ServerRegisterSchema) + .mutation(async ({ ctx, input }) => { + if (ctx.session) + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Tidak bisa membuat pengguna baru karena anda sudah login!", + }); + + const isUserExist = await preparedGetUserByEmail.execute({ + email: input.email, + }); + + if (isUserExist) + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Pengguna dengan email yang sama sudah terdaftar!", + }); + + const salt = bcrypt.genSaltSync(10); + const hash = bcrypt.hashSync(input.password, salt); + + const userTable = await countUserTable.execute(); + const autoAdmin = userTable.at(0).count < 1; + + await ctx.db.insert(schema.users).values({ + ...input, + password: hash, + verifiedAt: autoAdmin ? new Date() : null, + }); + + return { + success: true, + }; + }), + getSecretMessage: protectedProcedure.query(() => { return "you can see this secret message!"; }), diff --git a/packages/auth/src/config.ts b/packages/auth/src/config.ts index b51d34f4..e9bc47fd 100644 --- a/packages/auth/src/config.ts +++ b/packages/auth/src/config.ts @@ -1,9 +1,14 @@ import type { DefaultSession, NextAuthConfig } from "next-auth"; +// import { AuthError } from "next-auth" import bcrypt from "bcrypt"; import CredentialsProvider from "next-auth/providers/credentials"; import { preparedGetUserByEmail } from "@sora-vp/db"; +// class UserNotFoundError extends AuthError { +// static type = "UserNotFoundError"; +// } + declare module "next-auth" { interface Session { user: { @@ -44,6 +49,7 @@ export const authConfig = { id: user.id, name: user.name, email: user.email, + verifiedAt: user.verifiedAt, }; }, }), diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 68c7b3dd..3e95cde4 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -3,7 +3,9 @@ import { drizzle } from "drizzle-orm/mysql2"; import mysql from "mysql2/promise"; import { connectionStr } from "./config"; -import * as mainSchema from "./schema/main"; +import * as schema from "./schema/main"; + +export * as schema from "./schema/main"; export * from "drizzle-orm/sql"; export { alias } from "drizzle-orm/mysql-core"; @@ -11,13 +13,18 @@ export { alias } from "drizzle-orm/mysql-core"; const poolConnection = mysql.createPool(connectionStr.toString()); export const db = drizzle(poolConnection, { - schema: mainSchema, + schema, mode: "default", }); // Prepared statement stuff export const preparedGetUserByEmail = db.query.users .findFirst({ - where: eq(mainSchema.users.email, sql.placeholder("email")), + where: eq(schema.users.email, sql.placeholder("email")), }) .prepare(); + +export const countUserTable = db + .select({ count: sql`count(*)`.mapWith(Number) }) + .from(schema.users) + .prepare(); diff --git a/packages/db/src/schema/main.ts b/packages/db/src/schema/main.ts index aedce705..2b12158f 100644 --- a/packages/db/src/schema/main.ts +++ b/packages/db/src/schema/main.ts @@ -16,9 +16,7 @@ export const users = mySqlTable( name: text("name").notNull(), email: varchar("email", { length: 255 }).notNull(), password: varchar("password", { length: 255 }).notNull(), - createdAt: timestamp("submittedAt", { mode: "date" }) - .notNull() - .defaultNow(), + verifiedAt: timestamp("verified_at", { mode: "date" }), }, (users) => ({ emailIndex: uniqueIndex("email_unique_index").on(users.email), diff --git a/packages/ui/src/form.tsx b/packages/ui/src/form.tsx index 437bfcc1..9cadd1b8 100644 --- a/packages/ui/src/form.tsx +++ b/packages/ui/src/form.tsx @@ -9,10 +9,11 @@ import { FormProvider, useFormContext, } from "react-hook-form"; -import { Label } from "src//label"; import { cn } from "@sora-vp/ui"; +import { Label } from "./label"; + const Form = FormProvider; type FormFieldContextValue< diff --git a/packages/validators/src/auth.ts b/packages/validators/src/auth.ts new file mode 100644 index 00000000..e25e7e20 --- /dev/null +++ b/packages/validators/src/auth.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; + +const validNameRegex = + /^(?![ -.&,_'":?!])(?!.*[- &_'":]$)(?!.*[-.#@&,:?!]{2})[a-zA-Z- .,']+$/; + +const email = z + .string() + .min(1, { message: "Bidang email harus di isi!" }) + .email({ message: "Bidang email harus berupa email yang valid!" }); +const password = z + .string() + .min(1, { message: "Kata sandi harus di isi!" }) + .min(6, { message: "Kata sandi memiliki panjang setidaknya 6 karakter!" }); +const name = z + .string() + .min(3, { message: "Bidang nama harus di isi!" }) + .regex(validNameRegex, { + message: "Bidang nama harus berupa nama yang valid!", + }); + +const LoginFormSchema = z.object({ + email, + password, +}); + +const ServerRegisterSchema = z.object({ + email, + password, + name, +}); + +const RegisterFormSchema = ServerRegisterSchema.merge( + z.object({ + passConfirm: z.string().min(6, { + message: "Konfirmasi kata sandi diperlukan setidaknya 6 karakter!", + }), + }), +).refine((data) => data.password === data.passConfirm, { + message: "Konfirmasi kata sandi tidak sama!", + path: ["passConfirm"], +}); + +export const auth = { + LoginFormSchema, + RegisterFormSchema, + ServerRegisterSchema, +} as const; diff --git a/packages/validators/src/index.ts b/packages/validators/src/index.ts index d7637445..97ccf764 100644 --- a/packages/validators/src/index.ts +++ b/packages/validators/src/index.ts @@ -1,6 +1 @@ -import { z } from "zod"; - -export const CreatePostSchema = z.object({ - title: z.string().min(1), - content: z.string().min(1), -}); +export * from "./auth"; diff --git a/yarn.lock b/yarn.lock index a9f82142..7396796c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6748,9 +6748,9 @@ __metadata: linkType: hard "hono@npm:^4.1.4": - version: 4.3.3 - resolution: "hono@npm:4.3.3" - checksum: 10c0/2e02a563ab8461a56a97b59b1c31fd002179999a0323b3a44cbf8b69b92ad35cc8f38ba26a88b64caa71e2c1c39a1454d84473ed0c69f4e9573e7b3b064e0f58 + version: 4.3.8 + resolution: "hono@npm:4.3.8" + checksum: 10c0/2315ff79444aa6c7f181689d66cc9f44a9b079760d215b6e51ef1b7c4577b0395a5f53418404c436fa6392817899437aac28bac670402fb14885405c49d90c1a languageName: node linkType: hard From c2aebada1d7018cc885cb596c11ef0543e99cd86 Mon Sep 17 00:00:00 2001 From: Ezra Khairan Permana Date: Wed, 5 Jun 2024 20:45:09 +0700 Subject: [PATCH 11/44] feat: menambahkan fitur login dan logout --- apps/web/src/app/(auth)/layout.tsx | 27 ++-- .../src/app/_components/auth/login-page.tsx | 2 +- .../src/app/_components/nav/top-navbar.tsx | 145 ++++++++++++++++++ packages/api/src/router/auth.ts | 4 +- packages/auth/src/config.ts | 41 +++-- packages/db/src/schema/main.ts | 2 + packages/ui/package.json | 2 + packages/ui/src/alert-dialog.tsx | 142 +++++++++++++++++ packages/ui/src/avatar.tsx | 50 ++++++ yarn.lock | 83 ++++++++++ 10 files changed, 470 insertions(+), 28 deletions(-) create mode 100644 apps/web/src/app/_components/nav/top-navbar.tsx create mode 100644 packages/ui/src/alert-dialog.tsx create mode 100644 packages/ui/src/avatar.tsx diff --git a/apps/web/src/app/(auth)/layout.tsx b/apps/web/src/app/(auth)/layout.tsx index a07b8aed..deda2249 100644 --- a/apps/web/src/app/(auth)/layout.tsx +++ b/apps/web/src/app/(auth)/layout.tsx @@ -5,7 +5,6 @@ import { GeistMono } from "geist/font/mono"; import { GeistSans } from "geist/font/sans"; import { auth } from "@sora-vp/auth"; -import { preparedGetUserByEmail } from "@sora-vp/db"; import { cn } from "@sora-vp/ui"; import { ResizableHandle, @@ -19,6 +18,8 @@ import { TRPCReactProvider } from "~/trpc/react"; import "~/app/globals.css"; +import { TopNavbar } from "~/app/_components/nav/top-navbar"; + export const metadata: Metadata = { title: "sora baseline | aplikasi pemilihan", description: "Aplikasi pemilihan ketua yang baru.", @@ -40,11 +41,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) { if (!isLoggedIn) redirect("/login"); - const currentUser = await preparedGetUserByEmail.execute({ - email: isLoggedIn.user.email, - }); - - if (!currentUser!.verifiedAt) + if (!isLoggedIn.user.verifiedAt) return (

- Anda belum terverifikasi oleh administrator yang lain, mohon - konfirmasi ke panitia yang lain supaya anda bisa mengakses - dashboard admin. Jika sudah maka refresh halaman ini atau keluar - dan login kembali. + Anda belum terverifikasi oleh sistem, mohon konfirmasi ke + panitia yang lain supaya anda bisa mengakses dashboard admin. + Jika sudah maka refresh halaman ini atau keluar dan login + kembali.

@@ -100,11 +97,13 @@ export default async function RootLayout(props: { children: React.ReactNode }) { -
- Atas -
+
diff --git a/apps/web/src/app/_components/auth/login-page.tsx b/apps/web/src/app/_components/auth/login-page.tsx index 8793eaca..b2e64439 100644 --- a/apps/web/src/app/_components/auth/login-page.tsx +++ b/apps/web/src/app/_components/auth/login-page.tsx @@ -106,7 +106,7 @@ export function LoginComponent() { /> - Masukan email yang sudah di daftarkan sebelumnya. + Masukan kata sandi yang sudah di daftarkan sebelumnya. diff --git a/apps/web/src/app/_components/nav/top-navbar.tsx b/apps/web/src/app/_components/nav/top-navbar.tsx new file mode 100644 index 00000000..868d1a64 --- /dev/null +++ b/apps/web/src/app/_components/nav/top-navbar.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { signOut } from "next-auth/react"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@sora-vp/ui/alert-dialog"; +import { Avatar, AvatarFallback } from "@sora-vp/ui/avatar"; +import { Button } from "@sora-vp/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@sora-vp/ui/dropdown-menu"; +import { toast } from "@sora-vp/ui/toast"; + +interface Props { + name: string; + email: string; + nameFallback: string; +} + +export function TopNavbar({ name, email, nameFallback }: Props) { + const [logoutDialogOpen, setLogoutDialogOpen] = useState(false); + const [logoutLoading, setLogoutLoading] = useState(false); + + const router = useRouter(); + + return ( + <> +
+ + + + + + +
+

{name}

+

+ {email} +

+
+
+ + + + + + + Profil Anda + + + + + Tentang sora + + + + + + setLogoutDialogOpen(true)} + > + Keluar + +
+
+
+ + { + if (logoutLoading) return; + + setLogoutDialogOpen((prev) => !prev); + }} + > + + + Yakin Untuk Logout? + + Anda akan keluar dari akun ini, anda bisa login kembali di halaman + login. + + + + + Batal + + { + e.preventDefault(); + setLogoutLoading(true); + + void signOut({ redirect: false }) + .then(() => { + toast.success("Berhasil logout!"); + setLogoutLoading(false); + router.replace("/login"); + }) + .catch(() => { + toast.error("Gagal logout", { + description: "Mohon coba lagi nanti.", + }); + setLogoutLoading(false); + }); + }} + disabled={logoutLoading} + > + Lanjutkan + + + + + + ); +} diff --git a/packages/api/src/router/auth.ts b/packages/api/src/router/auth.ts index 24a0db14..0ac5201b 100644 --- a/packages/api/src/router/auth.ts +++ b/packages/api/src/router/auth.ts @@ -31,12 +31,14 @@ export const authRouter = { const hash = bcrypt.hashSync(input.password, salt); const userTable = await countUserTable.execute(); - const autoAdmin = userTable.at(0).count < 1; + const availUser = userTable.at(0); + const autoAdmin = availUser && availUser.count < 1; await ctx.db.insert(schema.users).values({ ...input, password: hash, verifiedAt: autoAdmin ? new Date() : null, + role: autoAdmin ? "admin" : null, }); return { diff --git a/packages/auth/src/config.ts b/packages/auth/src/config.ts index e9bc47fd..2b7983ef 100644 --- a/packages/auth/src/config.ts +++ b/packages/auth/src/config.ts @@ -1,18 +1,22 @@ import type { DefaultSession, NextAuthConfig } from "next-auth"; -// import { AuthError } from "next-auth" import bcrypt from "bcrypt"; +import { CredentialsSignin } from "next-auth"; import CredentialsProvider from "next-auth/providers/credentials"; import { preparedGetUserByEmail } from "@sora-vp/db"; -// class UserNotFoundError extends AuthError { -// static type = "UserNotFoundError"; -// } +class InvalidLoginError extends CredentialsSignin { + code = "Mohon masukan email dan kata sandi!"; +} +class InvalidUserOrPassword extends CredentialsSignin { + code = "Email atau kata sandi salah!"; +} declare module "next-auth" { interface Session { user: { - id: string; + verifiedAt: Date | null; + role: "admin" | "comittee" | null; } & DefaultSession["user"]; } } @@ -25,33 +29,46 @@ export const authConfig = { CredentialsProvider({ name: "credentials", credentials: { - email: { label: "Email", type: "text" }, - password: { label: "Password", type: "password" }, + email: { label: "Email" }, + password: { label: "Password" }, }, async authorize(credentials) { if (!credentials.email || !credentials.password) - throw new Error("Dibutuhkan email dan password!"); + throw new InvalidLoginError("Dibutuhkan email dan password!"); const user = await preparedGetUserByEmail.execute({ email: credentials.email, }); - if (!user) throw new Error("Pengguna tidak ditemukan!"); + if (!user) throw new InvalidUserOrPassword(); const isValidPassword = await bcrypt.compare( credentials.password as string, user.password, ); - if (!isValidPassword) throw new Error("Kata sandi salah!"); + if (!isValidPassword) throw new InvalidUserOrPassword(); return { - id: user.id, name: user.name, email: user.email, - verifiedAt: user.verifiedAt, }; }, }), ], + callbacks: { + jwt({ token }) { + return token; + }, + async session({ session }) { + const user = await preparedGetUserByEmail.execute({ + email: session.user.email, + }); + + session.user.role = user.role; + session.user.verifiedAt = user.verifiedAt; + + return session; + }, + }, } satisfies NextAuthConfig; diff --git a/packages/db/src/schema/main.ts b/packages/db/src/schema/main.ts index 2b12158f..371ce07c 100644 --- a/packages/db/src/schema/main.ts +++ b/packages/db/src/schema/main.ts @@ -1,6 +1,7 @@ import { boolean, int, + mysqlEnum, text, timestamp, uniqueIndex, @@ -17,6 +18,7 @@ export const users = mySqlTable( email: varchar("email", { length: 255 }).notNull(), password: varchar("password", { length: 255 }).notNull(), verifiedAt: timestamp("verified_at", { mode: "date" }), + role: mysqlEnum("role", ["admin", "comittee"]), }, (users) => ({ emailIndex: uniqueIndex("email_unique_index").on(users.email), diff --git a/packages/ui/package.json b/packages/ui/package.json index 288a9acc..c86d0b5a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -18,6 +18,8 @@ }, "dependencies": { "@hookform/resolvers": "^3.3.4", + "@radix-ui/react-alert-dialog": "^1.0.5", + "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.0.2", diff --git a/packages/ui/src/alert-dialog.tsx b/packages/ui/src/alert-dialog.tsx new file mode 100644 index 00000000..881dfbc7 --- /dev/null +++ b/packages/ui/src/alert-dialog.tsx @@ -0,0 +1,142 @@ +"use client"; + +import * as React from "react"; +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; + +import { cn } from "@sora-vp/ui"; + +import { buttonVariants } from "./button"; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = "AlertDialogHeader"; + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = "AlertDialogFooter"; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/packages/ui/src/avatar.tsx b/packages/ui/src/avatar.tsx new file mode 100644 index 00000000..99621a7b --- /dev/null +++ b/packages/ui/src/avatar.tsx @@ -0,0 +1,50 @@ +"use client"; + +import * as React from "react"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; + +import { cn } from "@sora-vp/ui"; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/yarn.lock b/yarn.lock index 7396796c..11d42cdc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1604,6 +1604,31 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-alert-dialog@npm:^1.0.5": + version: 1.0.5 + resolution: "@radix-ui/react-alert-dialog@npm:1.0.5" + dependencies: + "@babel/runtime": "npm:^7.13.10" + "@radix-ui/primitive": "npm:1.0.1" + "@radix-ui/react-compose-refs": "npm:1.0.1" + "@radix-ui/react-context": "npm:1.0.1" + "@radix-ui/react-dialog": "npm:1.0.5" + "@radix-ui/react-primitive": "npm:1.0.3" + "@radix-ui/react-slot": "npm:1.0.2" + peerDependencies: + "@types/react": "*" + "@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 + checksum: 10c0/73854a1011b07a50261a12ce33c4b9d6585603e731a2ceffc7a4d2b8c795631716fda8b8006a813648e247d17abbaf290a419a935ae4cd70c83c3c70a34ce9f4 + languageName: node + linkType: hard + "@radix-ui/react-arrow@npm:1.0.3": version: 1.0.3 resolution: "@radix-ui/react-arrow@npm:1.0.3" @@ -1624,6 +1649,29 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-avatar@npm:^1.0.4": + version: 1.0.4 + resolution: "@radix-ui/react-avatar@npm:1.0.4" + dependencies: + "@babel/runtime": "npm:^7.13.10" + "@radix-ui/react-context": "npm:1.0.1" + "@radix-ui/react-primitive": "npm:1.0.3" + "@radix-ui/react-use-callback-ref": "npm:1.0.1" + "@radix-ui/react-use-layout-effect": "npm:1.0.1" + peerDependencies: + "@types/react": "*" + "@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 + checksum: 10c0/608494c53968085bfcf9b987d80c3ec6720bdb65f78591d53e8bba3b360e86366d48a7dee11405dd443f5a3565432184b95bb9d4954bca1922cc9385a942caaf + languageName: node + linkType: hard + "@radix-ui/react-collection@npm:1.0.3": version: 1.0.3 resolution: "@radix-ui/react-collection@npm:1.0.3" @@ -1677,6 +1725,39 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-dialog@npm:1.0.5": + version: 1.0.5 + resolution: "@radix-ui/react-dialog@npm:1.0.5" + dependencies: + "@babel/runtime": "npm:^7.13.10" + "@radix-ui/primitive": "npm:1.0.1" + "@radix-ui/react-compose-refs": "npm:1.0.1" + "@radix-ui/react-context": "npm:1.0.1" + "@radix-ui/react-dismissable-layer": "npm:1.0.5" + "@radix-ui/react-focus-guards": "npm:1.0.1" + "@radix-ui/react-focus-scope": "npm:1.0.4" + "@radix-ui/react-id": "npm:1.0.1" + "@radix-ui/react-portal": "npm:1.0.4" + "@radix-ui/react-presence": "npm:1.0.1" + "@radix-ui/react-primitive": "npm:1.0.3" + "@radix-ui/react-slot": "npm:1.0.2" + "@radix-ui/react-use-controllable-state": "npm:1.0.1" + aria-hidden: "npm:^1.1.1" + react-remove-scroll: "npm:2.5.5" + peerDependencies: + "@types/react": "*" + "@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 + checksum: 10c0/c5b3069397379e79857a3203f3ead4d12d87736b59899f02a63e620a07dd1e6704e15523926cdf8e39afe1c945a7ff0f2533c5ea5be1e17c3114820300a51133 + languageName: node + linkType: hard + "@radix-ui/react-direction@npm:1.0.1": version: 1.0.1 resolution: "@radix-ui/react-direction@npm:1.0.1" @@ -2551,6 +2632,8 @@ __metadata: resolution: "@sora-vp/ui@workspace:packages/ui" dependencies: "@hookform/resolvers": "npm:^3.3.4" + "@radix-ui/react-alert-dialog": "npm:^1.0.5" + "@radix-ui/react-avatar": "npm:^1.0.4" "@radix-ui/react-dropdown-menu": "npm:^2.0.6" "@radix-ui/react-icons": "npm:^1.3.0" "@radix-ui/react-label": "npm:^2.0.2" From d933e8302551d219fbf501b7e38cb1439fbe0150 Mon Sep 17 00:00:00 2001 From: Ezra Khairan Permana Date: Thu, 6 Jun 2024 08:43:53 +0700 Subject: [PATCH 12/44] fix: memperbaiki turbo generator yang tidak sesuai dengan yarn --- apps/web/src/app/(auth)/admin/page.tsx | 25 +++- apps/web/src/app/(auth)/layout.tsx | 2 +- packages/settings/eslint.config.js | 9 ++ packages/settings/package.json | 25 ++++ packages/settings/src/index.ts | 1 + packages/settings/tsconfig.json | 8 ++ turbo/generators/config.ts | 18 ++- .../generators/templates/eslint.config.js.hbs | 2 +- turbo/generators/templates/package.json.hbs | 10 +- turbo/generators/templates/tsconfig.json.hbs | 2 +- yarn.lock | 110 +++++++++++++++++- 11 files changed, 193 insertions(+), 19 deletions(-) create mode 100644 packages/settings/eslint.config.js create mode 100644 packages/settings/package.json create mode 100644 packages/settings/src/index.ts create mode 100644 packages/settings/tsconfig.json diff --git a/apps/web/src/app/(auth)/admin/page.tsx b/apps/web/src/app/(auth)/admin/page.tsx index 0d4e1a6e..364c1270 100644 --- a/apps/web/src/app/(auth)/admin/page.tsx +++ b/apps/web/src/app/(auth)/admin/page.tsx @@ -1,5 +1,24 @@ -import { Button } from "@sora-vp/ui/button"; - export default function AdminPage() { - return <>; + return ( +
+
+

Beranda Admin

+

+ Kelola semua pengguna dan perilaku pengguna pada halaman ini. +

+
+ +
+

+ Seluruh Pengguna +

+
+ +
+

+ Menunggu Persetujuan +

+
+
+ ); } diff --git a/apps/web/src/app/(auth)/layout.tsx b/apps/web/src/app/(auth)/layout.tsx index deda2249..a2a70c4a 100644 --- a/apps/web/src/app/(auth)/layout.tsx +++ b/apps/web/src/app/(auth)/layout.tsx @@ -106,7 +106,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) { /> -
+
{props.children}
diff --git a/packages/settings/eslint.config.js b/packages/settings/eslint.config.js new file mode 100644 index 00000000..ebd17fb6 --- /dev/null +++ b/packages/settings/eslint.config.js @@ -0,0 +1,9 @@ +import baseConfig from "@sora-vp/eslint-config/base"; + +/** @type {import('typescript-eslint').Config} */ +export default [ + { + ignores: [], + }, + ...baseConfig, +]; diff --git a/packages/settings/package.json b/packages/settings/package.json new file mode 100644 index 00000000..61a04ee8 --- /dev/null +++ b/packages/settings/package.json @@ -0,0 +1,25 @@ +{ + "name": "@sora-vp/settings", + "private": true, + "version": "0.1.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "license": "MIT", + "scripts": { + "clean": "rm -rf .turbo node_modules", + "format": "prettier --check . --ignore-path ../../.gitignore", + "lint": "eslint", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@sora-vp/eslint-config": "*", + "@sora-vp/prettier-config": "*", + "@sora-vp/tsconfig": "*", + "eslint": "^9.0.0", + "prettier": "^3.2.5", + "typescript": "^5.4.3" + }, + "prettier": "@sora-vp/prettier-config" +} diff --git a/packages/settings/src/index.ts b/packages/settings/src/index.ts new file mode 100644 index 00000000..7672b11c --- /dev/null +++ b/packages/settings/src/index.ts @@ -0,0 +1 @@ +export const name = "settings"; diff --git a/packages/settings/tsconfig.json b/packages/settings/tsconfig.json new file mode 100644 index 00000000..1d9d3935 --- /dev/null +++ b/packages/settings/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@sora-vp/tsconfig/base.json", + "compilerOptions": { + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + }, + "include": ["*.ts", "src"], + "exclude": ["node_modules"] +} diff --git a/turbo/generators/config.ts b/turbo/generators/config.ts index d6bb5b5e..66906c4d 100644 --- a/turbo/generators/config.ts +++ b/turbo/generators/config.ts @@ -1,4 +1,5 @@ import { execSync } from "node:child_process"; +import path from "node:path"; import type { PlopTypes } from "@turbo/gen"; interface PackageJson { @@ -8,15 +9,17 @@ interface PackageJson { devDependencies: Record; } +const baseDir = path.join(__dirname, "../.."); + export default function generator(plop: PlopTypes.NodePlopAPI): void { plop.setGenerator("init", { - description: "Generate a new package for the Acme Monorepo", + description: "Generate a new package for the Sora Baseline Monorepo", prompts: [ { type: "input", name: "name", message: - "What is the name of the package? (You can skip the `@acme/` prefix)", + "What is the name of the package? (You can skip the `@sora-vp/` prefix)", }, { type: "input", @@ -28,8 +31,8 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void { actions: [ (answers) => { if ("name" in answers && typeof answers.name === "string") { - if (answers.name.startsWith("@acme/")) { - answers.name = answers.name.replace("@acme/", ""); + if (answers.name.startsWith("@sora-vp/")) { + answers.name = answers.name.replace("@sora-vp/", ""); } } return "Config sanitized"; @@ -82,9 +85,12 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void { // execSync("pnpm dlx sherif@latest --fix", { // stdio: "inherit", // }); - execSync("pnpm i", { stdio: "inherit" }); + execSync("yarn", { stdio: "inherit", cwd: baseDir }); execSync( - `pnpm prettier --write packages/${answers.name}/** --list-different`, + `yarn prettier --write packages/${answers.name}/** --list-different`, + { + cwd: baseDir, + }, ); return "Package scaffolded"; } diff --git a/turbo/generators/templates/eslint.config.js.hbs b/turbo/generators/templates/eslint.config.js.hbs index 2ab354b5..ebd17fb6 100644 --- a/turbo/generators/templates/eslint.config.js.hbs +++ b/turbo/generators/templates/eslint.config.js.hbs @@ -1,4 +1,4 @@ -import baseConfig from "@acme/eslint-config/base"; +import baseConfig from "@sora-vp/eslint-config/base"; /** @type {import('typescript-eslint').Config} */ export default [ diff --git a/turbo/generators/templates/package.json.hbs b/turbo/generators/templates/package.json.hbs index e4aedd02..3cb004ff 100644 --- a/turbo/generators/templates/package.json.hbs +++ b/turbo/generators/templates/package.json.hbs @@ -1,5 +1,5 @@ { - "name": "@acme/{{ name }}", + "name": "@sora-vp/{{ name }}", "private": true, "version": "0.1.0", "type": "module", @@ -14,12 +14,12 @@ "typecheck": "tsc --noEmit" }, "devDependencies": { - "@acme/eslint-config": "workspace:*", - "@acme/prettier-config": "workspace:*", - "@acme/tsconfig": "workspace:*", + "@sora-vp/eslint-config": "*", + "@sora-vp/prettier-config": "*", + "@sora-vp/tsconfig": "*", "eslint": "^9.0.0", "prettier": "^3.2.5", "typescript": "^5.4.3" }, - "prettier": "@acme/prettier-config" + "prettier": "@sora-vp/prettier-config" } diff --git a/turbo/generators/templates/tsconfig.json.hbs b/turbo/generators/templates/tsconfig.json.hbs index 7a26a270..1d9d3935 100644 --- a/turbo/generators/templates/tsconfig.json.hbs +++ b/turbo/generators/templates/tsconfig.json.hbs @@ -1,5 +1,5 @@ { - "extends": "@acme/tsconfig/base.json", + "extends": "@sora-vp/tsconfig/base.json", "compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" }, diff --git a/yarn.lock b/yarn.lock index 11d42cdc..26f2b5b1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -836,6 +836,17 @@ __metadata: languageName: node linkType: hard +"@eslint/config-array@npm:^0.15.1": + version: 0.15.1 + resolution: "@eslint/config-array@npm:0.15.1" + dependencies: + "@eslint/object-schema": "npm:^2.1.3" + debug: "npm:^4.3.1" + minimatch: "npm:^3.0.5" + checksum: 10c0/60947a188157f2f811cc2aedf3c2494fa10932178838f6a7c7e9a8bb106ab51b4b4e571f49ae63cdd3884002b78631e4395be25d4ae52470360fc7fb463303d2 + languageName: node + linkType: hard + "@eslint/eslintrc@npm:^3.0.2": version: 3.0.2 resolution: "@eslint/eslintrc@npm:3.0.2" @@ -853,6 +864,23 @@ __metadata: languageName: node linkType: hard +"@eslint/eslintrc@npm:^3.1.0": + version: 3.1.0 + resolution: "@eslint/eslintrc@npm:3.1.0" + dependencies: + ajv: "npm:^6.12.4" + debug: "npm:^4.3.2" + espree: "npm:^10.0.1" + globals: "npm:^14.0.0" + ignore: "npm:^5.2.0" + import-fresh: "npm:^3.2.1" + js-yaml: "npm:^4.1.0" + minimatch: "npm:^3.1.2" + strip-json-comments: "npm:^3.1.1" + checksum: 10c0/5b7332ed781edcfc98caa8dedbbb843abfb9bda2e86538529c843473f580e40c69eb894410eddc6702f487e9ee8f8cfa8df83213d43a8fdb549f23ce06699167 + languageName: node + linkType: hard + "@eslint/js@npm:9.2.0": version: 9.2.0 resolution: "@eslint/js@npm:9.2.0" @@ -860,6 +888,20 @@ __metadata: languageName: node linkType: hard +"@eslint/js@npm:9.4.0": + version: 9.4.0 + resolution: "@eslint/js@npm:9.4.0" + checksum: 10c0/7ffc508d3e9cd496cab7f08c5ba8f97851c8adaea3ebff8804b1c3b4662aa7aac7e9c3b597f7e47fdc29319a107bcf892865070a6b113c2e4d19f8fa1f99f569 + languageName: node + linkType: hard + +"@eslint/object-schema@npm:^2.1.3": + version: 2.1.3 + resolution: "@eslint/object-schema@npm:2.1.3" + checksum: 10c0/ee892d0112ee7ec86312dfb1fa718da76b2d446e3495b9ec1f3ef31382a335d31420b76f3def175b96f7c3517c88fc860fec049d62a81d444237a23881559403 + languageName: node + linkType: hard + "@fastify/busboy@npm:^2.0.0": version: 2.1.1 resolution: "@fastify/busboy@npm:2.1.1" @@ -963,6 +1005,13 @@ __metadata: languageName: node linkType: hard +"@humanwhocodes/retry@npm:^0.3.0": + version: 0.3.0 + resolution: "@humanwhocodes/retry@npm:0.3.0" + checksum: 10c0/7111ec4e098b1a428459b4e3be5a5d2a13b02905f805a2468f4fa628d072f0de2da26a27d04f65ea2846f73ba51f4204661709f05bfccff645e3cedef8781bb6 + languageName: node + linkType: hard + "@ianvs/prettier-plugin-sort-imports@npm:^4.2.1": version: 4.2.1 resolution: "@ianvs/prettier-plugin-sort-imports@npm:4.2.1" @@ -2605,6 +2654,19 @@ __metadata: languageName: unknown linkType: soft +"@sora-vp/settings@workspace:packages/settings": + version: 0.0.0-use.local + resolution: "@sora-vp/settings@workspace:packages/settings" + dependencies: + "@sora-vp/eslint-config": "npm:*" + "@sora-vp/prettier-config": "npm:*" + "@sora-vp/tsconfig": "npm:*" + eslint: "npm:^9.0.0" + prettier: "npm:^3.2.5" + typescript: "npm:^5.4.3" + languageName: unknown + linkType: soft + "@sora-vp/tailwind-config@npm:*, @sora-vp/tailwind-config@workspace:tooling/tailwind": version: 0.0.0-use.local resolution: "@sora-vp/tailwind-config@workspace:tooling/tailwind" @@ -5869,6 +5931,50 @@ __metadata: languageName: node linkType: hard +"eslint@npm:^9.0.0": + version: 9.4.0 + resolution: "eslint@npm:9.4.0" + dependencies: + "@eslint-community/eslint-utils": "npm:^4.2.0" + "@eslint-community/regexpp": "npm:^4.6.1" + "@eslint/config-array": "npm:^0.15.1" + "@eslint/eslintrc": "npm:^3.1.0" + "@eslint/js": "npm:9.4.0" + "@humanwhocodes/module-importer": "npm:^1.0.1" + "@humanwhocodes/retry": "npm:^0.3.0" + "@nodelib/fs.walk": "npm:^1.2.8" + ajv: "npm:^6.12.4" + chalk: "npm:^4.0.0" + cross-spawn: "npm:^7.0.2" + debug: "npm:^4.3.2" + escape-string-regexp: "npm:^4.0.0" + eslint-scope: "npm:^8.0.1" + eslint-visitor-keys: "npm:^4.0.0" + espree: "npm:^10.0.1" + esquery: "npm:^1.4.2" + esutils: "npm:^2.0.2" + fast-deep-equal: "npm:^3.1.3" + file-entry-cache: "npm:^8.0.0" + find-up: "npm:^5.0.0" + glob-parent: "npm:^6.0.2" + ignore: "npm:^5.2.0" + imurmurhash: "npm:^0.1.4" + is-glob: "npm:^4.0.0" + is-path-inside: "npm:^3.0.3" + json-stable-stringify-without-jsonify: "npm:^1.0.1" + levn: "npm:^0.4.1" + lodash.merge: "npm:^4.6.2" + minimatch: "npm:^3.1.2" + natural-compare: "npm:^1.4.0" + optionator: "npm:^0.9.3" + strip-ansi: "npm:^6.0.1" + text-table: "npm:^0.2.0" + bin: + eslint: bin/eslint.js + checksum: 10c0/826c901812536451e1bdb151359098db3a01ee9ff41775d5e97553626d07f7319cb2a0fd54176ef8e2e057105874077426b5d408ee6e8cff06bb814651f4c004 + languageName: node + linkType: hard + "eslint@npm:^9.2.0": version: 9.2.0 resolution: "eslint@npm:9.2.0" @@ -11446,7 +11552,7 @@ __metadata: languageName: node linkType: hard -"typescript@npm:^5.4.5": +"typescript@npm:^5.4.3, typescript@npm:^5.4.5": version: 5.4.5 resolution: "typescript@npm:5.4.5" bin: @@ -11456,7 +11562,7 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A^5.4.5#optional!builtin": +"typescript@patch:typescript@npm%3A^5.4.3#optional!builtin, typescript@patch:typescript@npm%3A^5.4.5#optional!builtin": version: 5.4.5 resolution: "typescript@patch:typescript@npm%3A5.4.5#optional!builtin::version=5.4.5&hash=5adc0c" bin: From 24e87ccf398ac80b03a3ea4a4d4baf88536a95da Mon Sep 17 00:00:00 2001 From: Ezra Khairan Permana Date: Thu, 6 Jun 2024 09:00:21 +0700 Subject: [PATCH 13/44] feat: mengembalikan settings --- packages/api/package.json | 1 + packages/api/src/root.ts | 2 + packages/api/src/router/post.ts | 40 ------------ packages/api/src/router/settings.ts | 13 ++++ packages/settings/src/SettingsManager.ts | 77 ++++++++++++++++++++++++ packages/settings/src/index.ts | 4 +- 6 files changed, 96 insertions(+), 41 deletions(-) delete mode 100644 packages/api/src/router/post.ts create mode 100644 packages/api/src/router/settings.ts create mode 100644 packages/settings/src/SettingsManager.ts diff --git a/packages/api/package.json b/packages/api/package.json index e0ae1fef..1adea769 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -21,6 +21,7 @@ "dependencies": { "@sora-vp/auth": "*", "@sora-vp/db": "*", + "@sora-vp/settings": "*", "@sora-vp/validators": "*", "@trpc/server": "11.0.0-rc.364", "bcrypt": "^5.1.1", diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index 61b8636c..5c801ac3 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -1,8 +1,10 @@ import { authRouter } from "./router/auth"; +import { settingsRouter } from "./router/settings"; import { createTRPCRouter } from "./trpc"; export const appRouter = createTRPCRouter({ auth: authRouter, + settings: settingsRouter, }); // export type definition of API diff --git a/packages/api/src/router/post.ts b/packages/api/src/router/post.ts deleted file mode 100644 index 393fa4d2..00000000 --- a/packages/api/src/router/post.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { TRPCRouterRecord } from "@trpc/server"; -import { z } from "zod"; - -import { desc, eq, schema } from "@sora-vp/db"; -import { CreatePostSchema } from "@sora-vp/validators"; - -import { protectedProcedure, publicProcedure } from "../trpc"; - -export const postRouter = { - all: publicProcedure.query(({ ctx }) => { - // return ctx.db.select().from(schema.post).orderBy(desc(schema.post.id)); - return ctx.db.query.post.findMany({ - orderBy: desc(schema.post.id), - limit: 10, - }); - }), - - byId: publicProcedure - .input(z.object({ id: z.number() })) - .query(({ ctx, input }) => { - // return ctx.db - // .select() - // .from(schema.post) - // .where(eq(schema.post.id, input.id)); - - return ctx.db.query.post.findFirst({ - where: eq(schema.post.id, input.id), - }); - }), - - create: protectedProcedure - .input(CreatePostSchema) - .mutation(({ ctx, input }) => { - return ctx.db.insert(schema.post).values(input); - }), - - delete: protectedProcedure.input(z.number()).mutation(({ ctx, input }) => { - return ctx.db.delete(schema.post).where(eq(schema.post.id, input)); - }), -} satisfies TRPCRouterRecord; diff --git a/packages/api/src/router/settings.ts b/packages/api/src/router/settings.ts new file mode 100644 index 00000000..34c05dab --- /dev/null +++ b/packages/api/src/router/settings.ts @@ -0,0 +1,13 @@ +import type { TRPCRouterRecord } from "@trpc/server"; +import { z } from "zod"; + +import settings from "@sora-vp/settings"; + +import { + // protectedProcedure, + publicProcedure, +} from "../trpc"; + +export const settingsRouter = { + getSettings: publicProcedure.query(() => settings.getSettings()), +} satisfies TRPCRouterRecord; diff --git a/packages/settings/src/SettingsManager.ts b/packages/settings/src/SettingsManager.ts new file mode 100644 index 00000000..2bbc72f5 --- /dev/null +++ b/packages/settings/src/SettingsManager.ts @@ -0,0 +1,77 @@ +import { EventEmitter } from "events"; + +interface AppSettings { + startTime: Date | null; + endTime: Date | null; + canVote: boolean | null; + canAttend: boolean | null; + canLogin: boolean | null; +} + +interface ReturnedValues { + startTime: Date | null; + endTime: Date | null; + canVote: boolean; + canAttend: boolean; + canLogin: boolean; +} + +type UpdateEventMap = { + update: ReturnedValues; +}; + +type ExtractValues = T extends unknown ? T[keyof T] : never; + +export class SettingsManager extends EventEmitter { + private settingsMap: Map>; + + constructor() { + super(); + this.settingsMap = new Map>(); + } + + getSettings(): ReturnedValues { + type DateOrUndef = Date | undefined; + type BoolOrUndef = boolean | undefined; + + const startTime = this.settingsMap.get("startTime") as DateOrUndef; + const endTime = this.settingsMap.get("endTime") as DateOrUndef; + + const canVote = this.settingsMap.get("canVote") as BoolOrUndef; + const canAttend = this.settingsMap.get("canAttend") as BoolOrUndef; + const canLogin = this.settingsMap.get("canLogin") as BoolOrUndef; + + return { + startTime: startTime ?? null, + endTime: endTime ?? null, + canVote: canVote ?? false, + canLogin: canLogin ?? true, + }; + } + + private updateBuilder( + key: keyof AppSettings, + value: ExtractValues, + ): void { + this.settingsMap.set(key, value); + this.emit("update", this.getSettings()); + } + + updateSettings = { + startTime: (time: Date) => this.updateBuilder("startTime", time), + endTime: (time: Date) => this.updateBuilder("endTime", time), + canVote: (votable: boolean) => this.updateBuilder("canVote", votable), + canAttend: (attendable: boolean) => + this.updateBuilder("canAttend", attendable), + canLogin: (status: boolean) => this.updateBuilder("canLogin", status), + } as const; + + on( + event: K, + listener: (payload: UpdateEventMap[K]) => void, + ): this { + return super.on(event, listener); + } +} + +export const settings = new SettingsManager(); diff --git a/packages/settings/src/index.ts b/packages/settings/src/index.ts index 7672b11c..c18795e3 100644 --- a/packages/settings/src/index.ts +++ b/packages/settings/src/index.ts @@ -1 +1,3 @@ -export const name = "settings"; +import { settings } from "./SettingsManager"; + +export default settings; From 99667b5832e2101b9340b5b21dba5dc1e068ffe7 Mon Sep 17 00:00:00 2001 From: Ezra Khairan Permana Date: Mon, 10 Jun 2024 11:00:11 +0700 Subject: [PATCH 14/44] chore: update versi --- apps/auth-proxy/package.json | 2 +- apps/processor/package.json | 2 +- apps/web/package.json | 2 +- .../src/app/(noauth)/(logreg)/login/page.tsx | 6 +-- .../app/(noauth)/(logreg)/register/page.tsx | 5 +-- .../src/app/_components/auth/login-page.tsx | 1 + packages/api/package.json | 2 +- packages/auth/package.json | 4 +- packages/auth/src/config.ts | 10 ++++- packages/db/package.json | 2 +- packages/id-generator/package.json | 2 +- packages/settings/package.json | 6 +-- packages/ui/package.json | 2 +- packages/validators/package.json | 2 +- tooling/eslint/package.json | 2 +- tooling/prettier/package.json | 2 +- tooling/tailwind/package.json | 2 +- yarn.lock | 41 +++++++++++++++---- 18 files changed, 62 insertions(+), 33 deletions(-) diff --git a/apps/auth-proxy/package.json b/apps/auth-proxy/package.json index ffe83d55..5ae2c420 100644 --- a/apps/auth-proxy/package.json +++ b/apps/auth-proxy/package.json @@ -26,4 +26,4 @@ "typescript": "^5.4.5" }, "prettier": "@sora-vp/prettier-config" -} +} \ No newline at end of file diff --git a/apps/processor/package.json b/apps/processor/package.json index 968141d8..85c700c1 100644 --- a/apps/processor/package.json +++ b/apps/processor/package.json @@ -37,4 +37,4 @@ "typescript": "^5.4.5" }, "prettier": "@sora-vp/prettier-config" -} +} \ No newline at end of file diff --git a/apps/web/package.json b/apps/web/package.json index 506733b5..398c21d6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -47,4 +47,4 @@ "typescript": "^5.4.5" }, "prettier": "@sora-vp/prettier-config" -} +} \ No newline at end of file diff --git a/apps/web/src/app/(noauth)/(logreg)/login/page.tsx b/apps/web/src/app/(noauth)/(logreg)/login/page.tsx index e2d137f7..18e7d47d 100644 --- a/apps/web/src/app/(noauth)/(logreg)/login/page.tsx +++ b/apps/web/src/app/(noauth)/(logreg)/login/page.tsx @@ -1,5 +1 @@ -import { LoginComponent } from "~/app/_components/auth/login-page"; - -export default function LoginPage() { - return ; -} +export { LoginComponent as default } from "~/app/_components/auth/login-page"; diff --git a/apps/web/src/app/(noauth)/(logreg)/register/page.tsx b/apps/web/src/app/(noauth)/(logreg)/register/page.tsx index 9cf5999c..7dab3e18 100644 --- a/apps/web/src/app/(noauth)/(logreg)/register/page.tsx +++ b/apps/web/src/app/(noauth)/(logreg)/register/page.tsx @@ -1,5 +1,2 @@ -import { RegistrationComponent } from "~/app/_components/auth/registration-page"; +export { RegistrationComponent as default } from "~/app/_components/auth/registration-page"; -export default function RegisterPage() { - return ; -} diff --git a/apps/web/src/app/_components/auth/login-page.tsx b/apps/web/src/app/_components/auth/login-page.tsx index b2e64439..53866fae 100644 --- a/apps/web/src/app/_components/auth/login-page.tsx +++ b/apps/web/src/app/_components/auth/login-page.tsx @@ -49,6 +49,7 @@ export function LoginComponent() { }); if (loginResult.error) { + console.log(loginResult) toast.error("Gagal login", { description: "Email atau password salah!" }); return; diff --git a/packages/api/package.json b/packages/api/package.json index 1adea769..f051037c 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -38,4 +38,4 @@ "typescript": "^5.4.5" }, "prettier": "@sora-vp/prettier-config" -} +} \ No newline at end of file diff --git a/packages/auth/package.json b/packages/auth/package.json index 90d4e25b..d6a0b5b0 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -22,7 +22,7 @@ "@t3-oss/env-nextjs": "^0.10.1", "bcrypt": "^5.1.1", "next": "^14.2.3", - "next-auth": "5.0.0-beta.17", + "next-auth": "5.0.0-beta.18", "react": "18.3.1", "react-dom": "18.3.1", "zod": "^3.23.6" @@ -37,4 +37,4 @@ "typescript": "^5.4.5" }, "prettier": "@sora-vp/prettier-config" -} +} \ No newline at end of file diff --git a/packages/auth/src/config.ts b/packages/auth/src/config.ts index 2b7983ef..74bde841 100644 --- a/packages/auth/src/config.ts +++ b/packages/auth/src/config.ts @@ -5,6 +5,9 @@ import CredentialsProvider from "next-auth/providers/credentials"; import { preparedGetUserByEmail } from "@sora-vp/db"; +class UnexpectedLoginError extends CredentialsSignin { + code = "Terjadi kesalahan yang terduga, mohon coba lagi nanti."; +} class InvalidLoginError extends CredentialsSignin { code = "Mohon masukan email dan kata sandi!"; } @@ -33,8 +36,9 @@ export const authConfig = { password: { label: "Password" }, }, async authorize(credentials) { + try { if (!credentials.email || !credentials.password) - throw new InvalidLoginError("Dibutuhkan email dan password!"); + throw new InvalidLoginError(); const user = await preparedGetUserByEmail.execute({ email: credentials.email, @@ -53,6 +57,10 @@ export const authConfig = { name: user.name, email: user.email, }; + } catch (_) { + throw new InvalidUserOrPassword; + + } }, }), ], diff --git a/packages/db/package.json b/packages/db/package.json index b579c73a..b36d42c0 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -38,4 +38,4 @@ "typescript": "^5.4.5" }, "prettier": "@sora-vp/prettier-config" -} +} \ No newline at end of file diff --git a/packages/id-generator/package.json b/packages/id-generator/package.json index 7125d219..bc77aa9f 100644 --- a/packages/id-generator/package.json +++ b/packages/id-generator/package.json @@ -21,4 +21,4 @@ "eslint": "^9.2.0", "typescript": "^5.4.5" } -} +} \ No newline at end of file diff --git a/packages/settings/package.json b/packages/settings/package.json index 61a04ee8..dfa6bacd 100644 --- a/packages/settings/package.json +++ b/packages/settings/package.json @@ -17,9 +17,9 @@ "@sora-vp/eslint-config": "*", "@sora-vp/prettier-config": "*", "@sora-vp/tsconfig": "*", - "eslint": "^9.0.0", + "eslint": "^9.2.0", "prettier": "^3.2.5", - "typescript": "^5.4.3" + "typescript": "^5.4.5" }, "prettier": "@sora-vp/prettier-config" -} +} \ No newline at end of file diff --git a/packages/ui/package.json b/packages/ui/package.json index c86d0b5a..ee547716 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -50,4 +50,4 @@ "zod": "^3.23.6" }, "prettier": "@sora-vp/prettier-config" -} +} \ No newline at end of file diff --git a/packages/validators/package.json b/packages/validators/package.json index 41f44cd7..cc3326a4 100644 --- a/packages/validators/package.json +++ b/packages/validators/package.json @@ -30,4 +30,4 @@ "typescript": "^5.4.5" }, "prettier": "@sora-vp/prettier-config" -} +} \ No newline at end of file diff --git a/tooling/eslint/package.json b/tooling/eslint/package.json index ef4a2536..1f948fdf 100644 --- a/tooling/eslint/package.json +++ b/tooling/eslint/package.json @@ -30,4 +30,4 @@ "typescript": "^5.4.5" }, "prettier": "@sora-vp/prettier-config" -} +} \ No newline at end of file diff --git a/tooling/prettier/package.json b/tooling/prettier/package.json index 2f44a0de..2bff76b5 100644 --- a/tooling/prettier/package.json +++ b/tooling/prettier/package.json @@ -21,4 +21,4 @@ "typescript": "^5.4.5" }, "prettier": "@sora-vp/prettier-config" -} +} \ No newline at end of file diff --git a/tooling/tailwind/package.json b/tooling/tailwind/package.json index 470e081d..4ae1f09c 100644 --- a/tooling/tailwind/package.json +++ b/tooling/tailwind/package.json @@ -28,4 +28,4 @@ "typescript": "^5.4.5" }, "prettier": "@sora-vp/prettier-config" -} +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 26f2b5b1..97866848 100644 --- a/yarn.lock +++ b/yarn.lock @@ -59,6 +59,32 @@ __metadata: languageName: node linkType: hard +"@auth/core@npm:0.31.0": + version: 0.31.0 + resolution: "@auth/core@npm:0.31.0" + dependencies: + "@panva/hkdf": "npm:^1.1.1" + "@types/cookie": "npm:0.6.0" + cookie: "npm:0.6.0" + jose: "npm:^5.1.3" + oauth4webapi: "npm:^2.4.0" + preact: "npm:10.11.3" + preact-render-to-string: "npm:5.2.3" + peerDependencies: + "@simplewebauthn/browser": ^9.0.1 + "@simplewebauthn/server": ^9.0.2 + nodemailer: ^6.8.0 + peerDependenciesMeta: + "@simplewebauthn/browser": + optional: true + "@simplewebauthn/server": + optional: true + nodemailer: + optional: true + checksum: 10c0/bc2c47e19f76f637924ba59015a69b81014bb0c163a7f68ba946e126e2bde9367c359feafd622928d23d32e84361acb72fe7b1becaa81c7bb2de1204a36d0975 + languageName: node + linkType: hard + "@babel/code-frame@npm:^7.23.5, @babel/code-frame@npm:^7.24.2": version: 7.24.2 resolution: "@babel/code-frame@npm:7.24.2" @@ -2501,6 +2527,7 @@ __metadata: "@sora-vp/db": "npm:*" "@sora-vp/eslint-config": "npm:*" "@sora-vp/prettier-config": "npm:*" + "@sora-vp/settings": "npm:*" "@sora-vp/tsconfig": "npm:*" "@sora-vp/validators": "npm:*" "@trpc/server": "npm:11.0.0-rc.364" @@ -2545,7 +2572,7 @@ __metadata: bcrypt: "npm:^5.1.1" eslint: "npm:^9.2.0" next: "npm:^14.2.3" - next-auth: "npm:5.0.0-beta.17" + next-auth: "npm:5.0.0-beta.18" prettier: "npm:^3.2.5" react: "npm:18.3.1" react-dom: "npm:18.3.1" @@ -2654,7 +2681,7 @@ __metadata: languageName: unknown linkType: soft -"@sora-vp/settings@workspace:packages/settings": +"@sora-vp/settings@npm:*, @sora-vp/settings@workspace:packages/settings": version: 0.0.0-use.local resolution: "@sora-vp/settings@workspace:packages/settings" dependencies: @@ -8524,11 +8551,11 @@ __metadata: languageName: node linkType: hard -"next-auth@npm:5.0.0-beta.17": - version: 5.0.0-beta.17 - resolution: "next-auth@npm:5.0.0-beta.17" +"next-auth@npm:5.0.0-beta.18": + version: 5.0.0-beta.18 + resolution: "next-auth@npm:5.0.0-beta.18" dependencies: - "@auth/core": "npm:0.30.0" + "@auth/core": "npm:0.31.0" peerDependencies: "@simplewebauthn/browser": ^9.0.1 "@simplewebauthn/server": ^9.0.2 @@ -8542,7 +8569,7 @@ __metadata: optional: true nodemailer: optional: true - checksum: 10c0/a69ea4f52b106313fc1a476eef1a0f3946f794dfe985c29feb2a592f241cb47e7bdcba7e8b5739c76c300ee0c0cb64e39be22cab152c7f52ff0c637af4c39371 + checksum: 10c0/d6f40de4c8018427b33da683f7c6d28d88dbe00c2fb2de9a13cdbaa55ff4724e8dc79a2dc45a3a6f0a2fb7ca95d689532db5fc3c5f6a4e8f2338e18f2f8ca5aa languageName: node linkType: hard From d7c5833eff61147cba5eb458ce01b7ce61362b8d Mon Sep 17 00:00:00 2001 From: Ezra Khairan Permana Date: Mon, 10 Jun 2024 12:51:48 +0700 Subject: [PATCH 15/44] feat: menambahkan toggle masih bisa login --- apps/auth-proxy/package.json | 2 +- apps/processor/package.json | 2 +- apps/web/package.json | 2 +- apps/web/src/app/(auth)/admin/page.tsx | 4 + apps/web/src/app/(noauth)/(logreg)/layout.tsx | 12 ++ .../app/(noauth)/(logreg)/register/page.tsx | 1 - .../_components/admin/toggle-can-login.tsx | 104 +++++++++++++ .../src/app/_components/auth/login-page.tsx | 2 +- packages/api/package.json | 2 +- packages/api/src/router/settings.ts | 17 ++- packages/auth/package.json | 2 +- packages/auth/src/config.ts | 33 ++-- packages/db/package.json | 2 +- packages/id-generator/package.json | 2 +- packages/settings/package.json | 2 +- packages/ui/package.json | 3 +- packages/ui/src/switch.tsx | 29 ++++ packages/validators/package.json | 2 +- tooling/eslint/package.json | 2 +- tooling/prettier/package.json | 2 +- tooling/tailwind/package.json | 2 +- yarn.lock | 143 ++++++------------ 22 files changed, 239 insertions(+), 133 deletions(-) create mode 100644 apps/web/src/app/_components/admin/toggle-can-login.tsx create mode 100644 packages/ui/src/switch.tsx diff --git a/apps/auth-proxy/package.json b/apps/auth-proxy/package.json index 5ae2c420..ffe83d55 100644 --- a/apps/auth-proxy/package.json +++ b/apps/auth-proxy/package.json @@ -26,4 +26,4 @@ "typescript": "^5.4.5" }, "prettier": "@sora-vp/prettier-config" -} \ No newline at end of file +} diff --git a/apps/processor/package.json b/apps/processor/package.json index 85c700c1..968141d8 100644 --- a/apps/processor/package.json +++ b/apps/processor/package.json @@ -37,4 +37,4 @@ "typescript": "^5.4.5" }, "prettier": "@sora-vp/prettier-config" -} \ No newline at end of file +} diff --git a/apps/web/package.json b/apps/web/package.json index 398c21d6..506733b5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -47,4 +47,4 @@ "typescript": "^5.4.5" }, "prettier": "@sora-vp/prettier-config" -} \ No newline at end of file +} diff --git a/apps/web/src/app/(auth)/admin/page.tsx b/apps/web/src/app/(auth)/admin/page.tsx index 364c1270..c90f6979 100644 --- a/apps/web/src/app/(auth)/admin/page.tsx +++ b/apps/web/src/app/(auth)/admin/page.tsx @@ -1,3 +1,5 @@ +import { ToggleCanLogin } from "~/app/_components/admin/toggle-can-login"; + export default function AdminPage() { return (
@@ -8,6 +10,8 @@ export default function AdminPage() {

+ +

Seluruh Pengguna diff --git a/apps/web/src/app/(noauth)/(logreg)/layout.tsx b/apps/web/src/app/(noauth)/(logreg)/layout.tsx index 9b664d59..bd37f78a 100644 --- a/apps/web/src/app/(noauth)/(logreg)/layout.tsx +++ b/apps/web/src/app/(noauth)/(logreg)/layout.tsx @@ -1,6 +1,7 @@ import { redirect } from "next/navigation"; import { auth } from "@sora-vp/auth"; +import settings from "@sora-vp/settings"; import { Toaster } from "@sora-vp/ui/toast"; import { TRPCReactProvider } from "~/trpc/react"; @@ -12,6 +13,17 @@ export default async function LogRegLayout(props: { if (alreadyLoggedIn) redirect("/admin"); + const { canLogin } = settings.getSettings(); + + if (!canLogin) + return ( +
+

+ Akses masuk ditolak. +

+
+ ); + return (
diff --git a/apps/web/src/app/(noauth)/(logreg)/register/page.tsx b/apps/web/src/app/(noauth)/(logreg)/register/page.tsx index 7dab3e18..d7eb5957 100644 --- a/apps/web/src/app/(noauth)/(logreg)/register/page.tsx +++ b/apps/web/src/app/(noauth)/(logreg)/register/page.tsx @@ -1,2 +1 @@ export { RegistrationComponent as default } from "~/app/_components/auth/registration-page"; - diff --git a/apps/web/src/app/_components/admin/toggle-can-login.tsx b/apps/web/src/app/_components/admin/toggle-can-login.tsx new file mode 100644 index 00000000..8d57f438 --- /dev/null +++ b/apps/web/src/app/_components/admin/toggle-can-login.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useEffect } from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, +} from "@sora-vp/ui/form"; +import { Switch } from "@sora-vp/ui/switch"; +import { toast } from "@sora-vp/ui/toast"; + +import { api } from "~/trpc/react"; + +const FormSchema = z.object({ + canLogin: z.boolean(), +}); + +export const ToggleCanLogin = () => { + const utils = api.useUtils(); + const form = useForm>({ + resolver: zodResolver(FormSchema), + }); + + const canLoginQuery = api.settings.getCanLoginStatus.useQuery(); + + useEffect(() => { + if (canLoginQuery.data) { + form.setValue("canLogin", canLoginQuery.data.canLogin); + } + }, [form.setValue, canLoginQuery.data]); + + const canLoginMutation = api.settings.updateCanLogin.useMutation({ + async onMutate(newValue) { + await utils.settings.getCanLoginStatus.cancel(); + + utils.settings.getCanLoginStatus.setData(undefined, () => newValue); + }, + onError(err) { + utils.settings.getCanLoginStatus.setData(undefined, { canLogin: false }); + form.setValue("canLogin", false); + + toast.error("Gagal memperbarui status login", { + description: err.message, + }); + }, + onSuccess() { + toast.success("Berhasil memperbarui status login!"); + }, + async onSettled() { + await utils.settings.getCanLoginStatus.invalidate(); + }, + }); + + const onSubmit = (data: z.infer) => + canLoginMutation.mutate(data); + + return ( +
+ +
+
+ ( + +
+ + Masih Bisa Login + + + Ini hanya berlaku untuk pengguna yang belum login, yang + sudah tidak akan terpengaruh sama sekali. + +
+ + { + field.onChange(val); + + canLoginMutation.mutate({ canLogin: val }); + }} + /> + +
+ )} + /> +
+
+
+ + ); +}; diff --git a/apps/web/src/app/_components/auth/login-page.tsx b/apps/web/src/app/_components/auth/login-page.tsx index 53866fae..3290b2f2 100644 --- a/apps/web/src/app/_components/auth/login-page.tsx +++ b/apps/web/src/app/_components/auth/login-page.tsx @@ -49,7 +49,7 @@ export function LoginComponent() { }); if (loginResult.error) { - console.log(loginResult) + console.log(loginResult); toast.error("Gagal login", { description: "Email atau password salah!" }); return; diff --git a/packages/api/package.json b/packages/api/package.json index f051037c..1adea769 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -38,4 +38,4 @@ "typescript": "^5.4.5" }, "prettier": "@sora-vp/prettier-config" -} \ No newline at end of file +} diff --git a/packages/api/src/router/settings.ts b/packages/api/src/router/settings.ts index 34c05dab..fb625863 100644 --- a/packages/api/src/router/settings.ts +++ b/packages/api/src/router/settings.ts @@ -3,11 +3,20 @@ import { z } from "zod"; import settings from "@sora-vp/settings"; -import { - // protectedProcedure, - publicProcedure, -} from "../trpc"; +import { protectedProcedure, publicProcedure } from "../trpc"; export const settingsRouter = { getSettings: publicProcedure.query(() => settings.getSettings()), + + getCanLoginStatus: protectedProcedure.query(() => { + const { canLogin } = settings.getSettings(); + + return { canLogin }; + }), + + updateCanLogin: protectedProcedure + .input(z.object({ canLogin: z.boolean() })) + .mutation(async ({ input }) => + settings.updateSettings.canLogin(input.canLogin), + ), } satisfies TRPCRouterRecord; diff --git a/packages/auth/package.json b/packages/auth/package.json index d6a0b5b0..cf38514b 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -37,4 +37,4 @@ "typescript": "^5.4.5" }, "prettier": "@sora-vp/prettier-config" -} \ No newline at end of file +} diff --git a/packages/auth/src/config.ts b/packages/auth/src/config.ts index 74bde841..871e09bc 100644 --- a/packages/auth/src/config.ts +++ b/packages/auth/src/config.ts @@ -37,29 +37,28 @@ export const authConfig = { }, async authorize(credentials) { try { - if (!credentials.email || !credentials.password) - throw new InvalidLoginError(); + if (!credentials.email || !credentials.password) + throw new InvalidLoginError(); - const user = await preparedGetUserByEmail.execute({ - email: credentials.email, - }); + const user = await preparedGetUserByEmail.execute({ + email: credentials.email, + }); - if (!user) throw new InvalidUserOrPassword(); + if (!user) throw new InvalidUserOrPassword(); - const isValidPassword = await bcrypt.compare( - credentials.password as string, - user.password, - ); + const isValidPassword = await bcrypt.compare( + credentials.password as string, + user.password, + ); - if (!isValidPassword) throw new InvalidUserOrPassword(); + if (!isValidPassword) throw new InvalidUserOrPassword(); - return { - name: user.name, - email: user.email, - }; + return { + name: user.name, + email: user.email, + }; } catch (_) { - throw new InvalidUserOrPassword; - + throw new InvalidUserOrPassword(); } }, }), diff --git a/packages/db/package.json b/packages/db/package.json index b36d42c0..b579c73a 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -38,4 +38,4 @@ "typescript": "^5.4.5" }, "prettier": "@sora-vp/prettier-config" -} \ No newline at end of file +} diff --git a/packages/id-generator/package.json b/packages/id-generator/package.json index bc77aa9f..7125d219 100644 --- a/packages/id-generator/package.json +++ b/packages/id-generator/package.json @@ -21,4 +21,4 @@ "eslint": "^9.2.0", "typescript": "^5.4.5" } -} \ No newline at end of file +} diff --git a/packages/settings/package.json b/packages/settings/package.json index dfa6bacd..90641e23 100644 --- a/packages/settings/package.json +++ b/packages/settings/package.json @@ -22,4 +22,4 @@ "typescript": "^5.4.5" }, "prettier": "@sora-vp/prettier-config" -} \ No newline at end of file +} diff --git a/packages/ui/package.json b/packages/ui/package.json index ee547716..2463792d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -24,6 +24,7 @@ "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-switch": "^1.0.3", "class-variance-authority": "^0.7.0", "next-themes": "^0.3.0", "react-hook-form": "^7.51.4", @@ -50,4 +51,4 @@ "zod": "^3.23.6" }, "prettier": "@sora-vp/prettier-config" -} \ No newline at end of file +} diff --git a/packages/ui/src/switch.tsx b/packages/ui/src/switch.tsx new file mode 100644 index 00000000..2d3b0599 --- /dev/null +++ b/packages/ui/src/switch.tsx @@ -0,0 +1,29 @@ +"use client"; + +import * as React from "react"; +import * as SwitchPrimitives from "@radix-ui/react-switch"; + +import { cn } from "@sora-vp/ui"; + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +Switch.displayName = SwitchPrimitives.Root.displayName; + +export { Switch }; diff --git a/packages/validators/package.json b/packages/validators/package.json index cc3326a4..41f44cd7 100644 --- a/packages/validators/package.json +++ b/packages/validators/package.json @@ -30,4 +30,4 @@ "typescript": "^5.4.5" }, "prettier": "@sora-vp/prettier-config" -} \ No newline at end of file +} diff --git a/tooling/eslint/package.json b/tooling/eslint/package.json index 1f948fdf..ef4a2536 100644 --- a/tooling/eslint/package.json +++ b/tooling/eslint/package.json @@ -30,4 +30,4 @@ "typescript": "^5.4.5" }, "prettier": "@sora-vp/prettier-config" -} \ No newline at end of file +} diff --git a/tooling/prettier/package.json b/tooling/prettier/package.json index 2bff76b5..2f44a0de 100644 --- a/tooling/prettier/package.json +++ b/tooling/prettier/package.json @@ -21,4 +21,4 @@ "typescript": "^5.4.5" }, "prettier": "@sora-vp/prettier-config" -} \ No newline at end of file +} diff --git a/tooling/tailwind/package.json b/tooling/tailwind/package.json index 4ae1f09c..470e081d 100644 --- a/tooling/tailwind/package.json +++ b/tooling/tailwind/package.json @@ -28,4 +28,4 @@ "typescript": "^5.4.5" }, "prettier": "@sora-vp/prettier-config" -} \ No newline at end of file +} diff --git a/yarn.lock b/yarn.lock index 97866848..7228a7aa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -862,17 +862,6 @@ __metadata: languageName: node linkType: hard -"@eslint/config-array@npm:^0.15.1": - version: 0.15.1 - resolution: "@eslint/config-array@npm:0.15.1" - dependencies: - "@eslint/object-schema": "npm:^2.1.3" - debug: "npm:^4.3.1" - minimatch: "npm:^3.0.5" - checksum: 10c0/60947a188157f2f811cc2aedf3c2494fa10932178838f6a7c7e9a8bb106ab51b4b4e571f49ae63cdd3884002b78631e4395be25d4ae52470360fc7fb463303d2 - languageName: node - linkType: hard - "@eslint/eslintrc@npm:^3.0.2": version: 3.0.2 resolution: "@eslint/eslintrc@npm:3.0.2" @@ -890,23 +879,6 @@ __metadata: languageName: node linkType: hard -"@eslint/eslintrc@npm:^3.1.0": - version: 3.1.0 - resolution: "@eslint/eslintrc@npm:3.1.0" - dependencies: - ajv: "npm:^6.12.4" - debug: "npm:^4.3.2" - espree: "npm:^10.0.1" - globals: "npm:^14.0.0" - ignore: "npm:^5.2.0" - import-fresh: "npm:^3.2.1" - js-yaml: "npm:^4.1.0" - minimatch: "npm:^3.1.2" - strip-json-comments: "npm:^3.1.1" - checksum: 10c0/5b7332ed781edcfc98caa8dedbbb843abfb9bda2e86538529c843473f580e40c69eb894410eddc6702f487e9ee8f8cfa8df83213d43a8fdb549f23ce06699167 - languageName: node - linkType: hard - "@eslint/js@npm:9.2.0": version: 9.2.0 resolution: "@eslint/js@npm:9.2.0" @@ -914,20 +886,6 @@ __metadata: languageName: node linkType: hard -"@eslint/js@npm:9.4.0": - version: 9.4.0 - resolution: "@eslint/js@npm:9.4.0" - checksum: 10c0/7ffc508d3e9cd496cab7f08c5ba8f97851c8adaea3ebff8804b1c3b4662aa7aac7e9c3b597f7e47fdc29319a107bcf892865070a6b113c2e4d19f8fa1f99f569 - languageName: node - linkType: hard - -"@eslint/object-schema@npm:^2.1.3": - version: 2.1.3 - resolution: "@eslint/object-schema@npm:2.1.3" - checksum: 10c0/ee892d0112ee7ec86312dfb1fa718da76b2d446e3495b9ec1f3ef31382a335d31420b76f3def175b96f7c3517c88fc860fec049d62a81d444237a23881559403 - languageName: node - linkType: hard - "@fastify/busboy@npm:^2.0.0": version: 2.1.1 resolution: "@fastify/busboy@npm:2.1.1" @@ -1031,13 +989,6 @@ __metadata: languageName: node linkType: hard -"@humanwhocodes/retry@npm:^0.3.0": - version: 0.3.0 - resolution: "@humanwhocodes/retry@npm:0.3.0" - checksum: 10c0/7111ec4e098b1a428459b4e3be5a5d2a13b02905f805a2468f4fa628d072f0de2da26a27d04f65ea2846f73ba51f4204661709f05bfccff645e3cedef8781bb6 - languageName: node - linkType: hard - "@ianvs/prettier-plugin-sort-imports@npm:^4.2.1": version: 4.2.1 resolution: "@ianvs/prettier-plugin-sort-imports@npm:4.2.1" @@ -2151,6 +2102,32 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-switch@npm:^1.0.3": + version: 1.0.3 + resolution: "@radix-ui/react-switch@npm:1.0.3" + dependencies: + "@babel/runtime": "npm:^7.13.10" + "@radix-ui/primitive": "npm:1.0.1" + "@radix-ui/react-compose-refs": "npm:1.0.1" + "@radix-ui/react-context": "npm:1.0.1" + "@radix-ui/react-primitive": "npm:1.0.3" + "@radix-ui/react-use-controllable-state": "npm:1.0.1" + "@radix-ui/react-use-previous": "npm:1.0.1" + "@radix-ui/react-use-size": "npm:1.0.1" + peerDependencies: + "@types/react": "*" + "@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 + checksum: 10c0/e7c65aeedf9d3cd47320fd3759b8c7f3777619cd847a96f2c52841488ad1745fa35335e2877a4f839902942410a7ffe9baf05ec1c249a0401a2b1b9363dbf343 + languageName: node + linkType: hard + "@radix-ui/react-use-callback-ref@npm:1.0.1": version: 1.0.1 resolution: "@radix-ui/react-use-callback-ref@npm:1.0.1" @@ -2213,6 +2190,21 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-use-previous@npm:1.0.1": + version: 1.0.1 + resolution: "@radix-ui/react-use-previous@npm:1.0.1" + dependencies: + "@babel/runtime": "npm:^7.13.10" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/f5fbc602108668484a4ed506b7842482222d1d03094362e26abb7fdd593eee8794fc47d85b3524fb9d00884801c89a6eefd0bed0971eba1ec189c637b6afd398 + languageName: node + linkType: hard + "@radix-ui/react-use-rect@npm:1.0.1": version: 1.0.1 resolution: "@radix-ui/react-use-rect@npm:1.0.1" @@ -2688,9 +2680,9 @@ __metadata: "@sora-vp/eslint-config": "npm:*" "@sora-vp/prettier-config": "npm:*" "@sora-vp/tsconfig": "npm:*" - eslint: "npm:^9.0.0" + eslint: "npm:^9.2.0" prettier: "npm:^3.2.5" - typescript: "npm:^5.4.3" + typescript: "npm:^5.4.5" languageName: unknown linkType: soft @@ -2727,6 +2719,7 @@ __metadata: "@radix-ui/react-icons": "npm:^1.3.0" "@radix-ui/react-label": "npm:^2.0.2" "@radix-ui/react-slot": "npm:^1.0.2" + "@radix-ui/react-switch": "npm:^1.0.3" "@sora-vp/eslint-config": "npm:*" "@sora-vp/prettier-config": "npm:*" "@sora-vp/tailwind-config": "npm:*" @@ -5958,50 +5951,6 @@ __metadata: languageName: node linkType: hard -"eslint@npm:^9.0.0": - version: 9.4.0 - resolution: "eslint@npm:9.4.0" - dependencies: - "@eslint-community/eslint-utils": "npm:^4.2.0" - "@eslint-community/regexpp": "npm:^4.6.1" - "@eslint/config-array": "npm:^0.15.1" - "@eslint/eslintrc": "npm:^3.1.0" - "@eslint/js": "npm:9.4.0" - "@humanwhocodes/module-importer": "npm:^1.0.1" - "@humanwhocodes/retry": "npm:^0.3.0" - "@nodelib/fs.walk": "npm:^1.2.8" - ajv: "npm:^6.12.4" - chalk: "npm:^4.0.0" - cross-spawn: "npm:^7.0.2" - debug: "npm:^4.3.2" - escape-string-regexp: "npm:^4.0.0" - eslint-scope: "npm:^8.0.1" - eslint-visitor-keys: "npm:^4.0.0" - espree: "npm:^10.0.1" - esquery: "npm:^1.4.2" - esutils: "npm:^2.0.2" - fast-deep-equal: "npm:^3.1.3" - file-entry-cache: "npm:^8.0.0" - find-up: "npm:^5.0.0" - glob-parent: "npm:^6.0.2" - ignore: "npm:^5.2.0" - imurmurhash: "npm:^0.1.4" - is-glob: "npm:^4.0.0" - is-path-inside: "npm:^3.0.3" - json-stable-stringify-without-jsonify: "npm:^1.0.1" - levn: "npm:^0.4.1" - lodash.merge: "npm:^4.6.2" - minimatch: "npm:^3.1.2" - natural-compare: "npm:^1.4.0" - optionator: "npm:^0.9.3" - strip-ansi: "npm:^6.0.1" - text-table: "npm:^0.2.0" - bin: - eslint: bin/eslint.js - checksum: 10c0/826c901812536451e1bdb151359098db3a01ee9ff41775d5e97553626d07f7319cb2a0fd54176ef8e2e057105874077426b5d408ee6e8cff06bb814651f4c004 - languageName: node - linkType: hard - "eslint@npm:^9.2.0": version: 9.2.0 resolution: "eslint@npm:9.2.0" @@ -11579,7 +11528,7 @@ __metadata: languageName: node linkType: hard -"typescript@npm:^5.4.3, typescript@npm:^5.4.5": +"typescript@npm:^5.4.5": version: 5.4.5 resolution: "typescript@npm:5.4.5" bin: @@ -11589,7 +11538,7 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A^5.4.3#optional!builtin, typescript@patch:typescript@npm%3A^5.4.5#optional!builtin": +"typescript@patch:typescript@npm%3A^5.4.5#optional!builtin": version: 5.4.5 resolution: "typescript@patch:typescript@npm%3A5.4.5#optional!builtin::version=5.4.5&hash=5adc0c" bin: From 3547fa1ac56480ba6dfe5368054bbb1a1a4ada0c Mon Sep 17 00:00:00 2001 From: Ezra Khairan Permana Date: Mon, 10 Jun 2024 13:13:53 +0700 Subject: [PATCH 16/44] feat: persiapan penambahan halaman baru --- apps/web/src/app/(auth)/admin/(adminRole)/kandidat/page.tsx | 3 +++ apps/web/src/app/(auth)/admin/{ => (adminRole)}/page.tsx | 0 apps/web/src/app/(auth)/admin/(adminRole)/pengaturan/page.tsx | 3 +++ apps/web/src/app/(auth)/admin/(adminRole)/statistik/page.tsx | 3 +++ 4 files changed, 9 insertions(+) create mode 100644 apps/web/src/app/(auth)/admin/(adminRole)/kandidat/page.tsx rename apps/web/src/app/(auth)/admin/{ => (adminRole)}/page.tsx (100%) create mode 100644 apps/web/src/app/(auth)/admin/(adminRole)/pengaturan/page.tsx create mode 100644 apps/web/src/app/(auth)/admin/(adminRole)/statistik/page.tsx diff --git a/apps/web/src/app/(auth)/admin/(adminRole)/kandidat/page.tsx b/apps/web/src/app/(auth)/admin/(adminRole)/kandidat/page.tsx new file mode 100644 index 00000000..83291b32 --- /dev/null +++ b/apps/web/src/app/(auth)/admin/(adminRole)/kandidat/page.tsx @@ -0,0 +1,3 @@ +export default function CandidatePage() { + return <>; +} diff --git a/apps/web/src/app/(auth)/admin/page.tsx b/apps/web/src/app/(auth)/admin/(adminRole)/page.tsx similarity index 100% rename from apps/web/src/app/(auth)/admin/page.tsx rename to apps/web/src/app/(auth)/admin/(adminRole)/page.tsx diff --git a/apps/web/src/app/(auth)/admin/(adminRole)/pengaturan/page.tsx b/apps/web/src/app/(auth)/admin/(adminRole)/pengaturan/page.tsx new file mode 100644 index 00000000..fe25cb52 --- /dev/null +++ b/apps/web/src/app/(auth)/admin/(adminRole)/pengaturan/page.tsx @@ -0,0 +1,3 @@ +export default function SettingsPage() { + return <>; +} diff --git a/apps/web/src/app/(auth)/admin/(adminRole)/statistik/page.tsx b/apps/web/src/app/(auth)/admin/(adminRole)/statistik/page.tsx new file mode 100644 index 00000000..92df6b28 --- /dev/null +++ b/apps/web/src/app/(auth)/admin/(adminRole)/statistik/page.tsx @@ -0,0 +1,3 @@ +export default function StatisticPage() { + return <>; +} From cbd8d41f0d41a401921080243166c57816ca5c48 Mon Sep 17 00:00:00 2001 From: Ezra Khairan Permana Date: Mon, 10 Jun 2024 14:48:22 +0700 Subject: [PATCH 17/44] fix: merapihkan tampilan --- apps/web/src/app/(auth)/layout.tsx | 50 ++-------- .../app/_components/nav/resizeable-nav.tsx | 85 ++++++++++++++++ .../src/app/_components/nav/top-navbar.tsx | 98 +++++++++---------- 3 files changed, 142 insertions(+), 91 deletions(-) create mode 100644 apps/web/src/app/_components/nav/resizeable-nav.tsx diff --git a/apps/web/src/app/(auth)/layout.tsx b/apps/web/src/app/(auth)/layout.tsx index a2a70c4a..e0a05cb7 100644 --- a/apps/web/src/app/(auth)/layout.tsx +++ b/apps/web/src/app/(auth)/layout.tsx @@ -1,16 +1,10 @@ import type { Metadata, Viewport } from "next"; -import localFont from "next/font/local"; import { redirect } from "next/navigation"; import { GeistMono } from "geist/font/mono"; import { GeistSans } from "geist/font/sans"; import { auth } from "@sora-vp/auth"; import { cn } from "@sora-vp/ui"; -import { - ResizableHandle, - ResizablePanel, - ResizablePanelGroup, -} from "@sora-vp/ui/resizable"; import { ThemeProvider, ThemeToggle } from "@sora-vp/ui/theme"; import { Toaster } from "@sora-vp/ui/toast"; @@ -18,7 +12,7 @@ import { TRPCReactProvider } from "~/trpc/react"; import "~/app/globals.css"; -import { TopNavbar } from "~/app/_components/nav/top-navbar"; +import { ResizeableNav } from "~/app/_components/nav/resizeable-nav"; export const metadata: Metadata = { title: "sora baseline | aplikasi pemilihan", @@ -32,10 +26,6 @@ export const viewport: Viewport = { ], }; -const sundaneseFont = localFont({ - src: "../fonts/NotoSansSundanese-Regular.ttf", -}); - export default async function RootLayout(props: { children: React.ReactNode }) { const isLoggedIn = await auth(); @@ -82,37 +72,15 @@ export default async function RootLayout(props: { children: React.ReactNode }) { )} > - - -
- ᮞᮧᮛ -
-
- - - - - - - -
- {props.children} -
-
-
-
-
+
+ {props.children} +
+
diff --git a/apps/web/src/app/_components/nav/resizeable-nav.tsx b/apps/web/src/app/_components/nav/resizeable-nav.tsx new file mode 100644 index 00000000..f1fcfcea --- /dev/null +++ b/apps/web/src/app/_components/nav/resizeable-nav.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useState } from "react"; +import localFont from "next/font/local"; + +import { cn } from "@sora-vp/ui"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@sora-vp/ui/resizable"; + +import type { Props as TopNavbarProps } from "./top-navbar"; +import { TopNavbar } from "./top-navbar"; + +interface Props { + children: React.ReacNode; +} + +const sundaneseFont = localFont({ + src: "../../fonts/NotoSansSundanese-Regular.ttf", +}); + +export function ResizeableNav({ + name, + nameFallback, + email, + children, +}: Props & TopNavbarProps) { + const [isCollapsed, setIsCollapsed] = useState(false); + + return ( + + { + setIsCollapsed(true); + }} + onExpand={() => setIsCollapsed(false)} + className={cn( + isCollapsed && "min-w-[45px] transition-all duration-300 ease-in-out", + )} + > + {!isCollapsed ? ( +
+ ᮞᮧᮛ +
+ ) : null} +
+ + + + +
+ {isCollapsed ? ( +
+ ᮞᮧᮛ +
+ ) : null} + + +
+
+ + {children} + +
+
+
+ ); +} diff --git a/apps/web/src/app/_components/nav/top-navbar.tsx b/apps/web/src/app/_components/nav/top-navbar.tsx index 868d1a64..e84f7e7e 100644 --- a/apps/web/src/app/_components/nav/top-navbar.tsx +++ b/apps/web/src/app/_components/nav/top-navbar.tsx @@ -28,7 +28,7 @@ import { } from "@sora-vp/ui/dropdown-menu"; import { toast } from "@sora-vp/ui/toast"; -interface Props { +export interface Props { name: string; email: string; nameFallback: string; @@ -42,58 +42,56 @@ export function TopNavbar({ name, email, nameFallback }: Props) { return ( <> -
- - - - - - -
-

{name}

-

- {email} -

-
-
- - + + + + + + +
+

{name}

+

+ {email} +

+
+
- - - - Profil Anda - - - - - Tentang sora - - - + - - setLogoutDialogOpen(true)} - > - Keluar + + + + Profil Anda + -
-
-
+ + + Tentang sora + + + + + + setLogoutDialogOpen(true)} + > + Keluar + + + Date: Mon, 10 Jun 2024 15:47:25 +0700 Subject: [PATCH 18/44] feat: menambahkan halaman partisipan --- apps/web/package.json | 1 + .../src/app/(auth)/admin/partisipan/page.tsx | 3 + apps/web/src/app/(auth)/layout.tsx | 1 + .../web/src/app/_components/nav/nav-items.tsx | 64 +++++++++++++++++++ .../app/_components/nav/resizeable-nav.tsx | 44 ++++++++++++- packages/ui/package.json | 1 + packages/ui/src/tooltip.tsx | 30 +++++++++ yarn.lock | 62 ++++++++++++++++++ 8 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/app/(auth)/admin/partisipan/page.tsx create mode 100644 apps/web/src/app/_components/nav/nav-items.tsx create mode 100644 packages/ui/src/tooltip.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 506733b5..973d52e1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -25,6 +25,7 @@ "@trpc/react-query": "11.0.0-rc.364", "@trpc/server": "11.0.0-rc.364", "geist": "^1.3.0", + "lucide-react": "^0.390.0", "next": "^14.2.3", "react": "18.3.1", "react-dom": "18.3.1", diff --git a/apps/web/src/app/(auth)/admin/partisipan/page.tsx b/apps/web/src/app/(auth)/admin/partisipan/page.tsx new file mode 100644 index 00000000..1ae95d29 --- /dev/null +++ b/apps/web/src/app/(auth)/admin/partisipan/page.tsx @@ -0,0 +1,3 @@ +export default function ParticipantPage() { + return <>; +} diff --git a/apps/web/src/app/(auth)/layout.tsx b/apps/web/src/app/(auth)/layout.tsx index e0a05cb7..20f46312 100644 --- a/apps/web/src/app/(auth)/layout.tsx +++ b/apps/web/src/app/(auth)/layout.tsx @@ -76,6 +76,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) { name={isLoggedIn.user.name ?? ""} email={isLoggedIn.user.email ?? ""} nameFallback={isLoggedIn.user.name?.slice(0, 2) ?? ""} + role={isLoggedIn.user.role} >
{props.children} diff --git a/apps/web/src/app/_components/nav/nav-items.tsx b/apps/web/src/app/_components/nav/nav-items.tsx new file mode 100644 index 00000000..d6bd9b11 --- /dev/null +++ b/apps/web/src/app/_components/nav/nav-items.tsx @@ -0,0 +1,64 @@ +import type { LucideIcon } from "lucide-react"; +import Link from "next/link"; + +import { cn } from "@sora-vp/ui"; +import { buttonVariants } from "@sora-vp/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@sora-vp/ui/tooltip"; + +interface NavProps { + isCollapsed: boolean; + links: { + title: string; + icon: LucideIcon; + href: string; + }[]; +} + +export function NavItems({ links, isCollapsed }: NavProps) { + return ( +
+ +
+ ); +} diff --git a/apps/web/src/app/_components/nav/resizeable-nav.tsx b/apps/web/src/app/_components/nav/resizeable-nav.tsx index f1fcfcea..5a212e94 100644 --- a/apps/web/src/app/_components/nav/resizeable-nav.tsx +++ b/apps/web/src/app/_components/nav/resizeable-nav.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import localFont from "next/font/local"; +import { Home, LineChart, Settings, User, Users } from "lucide-react"; import { cn } from "@sora-vp/ui"; import { @@ -9,22 +10,56 @@ import { ResizablePanel, ResizablePanelGroup, } from "@sora-vp/ui/resizable"; +import { TooltipProvider } from "@sora-vp/ui/tooltip"; import type { Props as TopNavbarProps } from "./top-navbar"; +import { NavItems } from "./nav-items"; import { TopNavbar } from "./top-navbar"; interface Props { children: React.ReacNode; + role: "admin" | "comittee"; } const sundaneseFont = localFont({ src: "../../fonts/NotoSansSundanese-Regular.ttf", }); +const participantNav = { + title: "Partisipan", + icon: Users, + href: "/admin/partisipan", +}; + +const adminNav = [ + { + title: "Beranda", + icon: Home, + href: "/admin", + }, + { + title: "Kandidat", + icon: User, + href: "/admin/kandidat", + }, + participantNav, + { + title: "Statistik", + icon: LineChart, + href: "/admin/statistik", + }, + { + title: "Pengaturan", + icon: Settings, + href: "/admin/pengaturan", + }, +]; + export function ResizeableNav({ name, nameFallback, email, + role, children, }: Props & TopNavbarProps) { const [isCollapsed, setIsCollapsed] = useState(false); @@ -44,7 +79,7 @@ export function ResizeableNav({ }} onExpand={() => setIsCollapsed(false)} className={cn( - isCollapsed && "min-w-[45px] transition-all duration-300 ease-in-out", + isCollapsed && "min-w-[55px] transition-all duration-300 ease-in-out", )} > {!isCollapsed ? ( @@ -52,6 +87,13 @@ export function ResizeableNav({ ᮞᮧᮛ
) : null} + + + + diff --git a/packages/ui/package.json b/packages/ui/package.json index 2463792d..c359bbae 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -25,6 +25,7 @@ "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-switch": "^1.0.3", + "@radix-ui/react-tooltip": "^1.0.7", "class-variance-authority": "^0.7.0", "next-themes": "^0.3.0", "react-hook-form": "^7.51.4", diff --git a/packages/ui/src/tooltip.tsx b/packages/ui/src/tooltip.tsx new file mode 100644 index 00000000..52d26025 --- /dev/null +++ b/packages/ui/src/tooltip.tsx @@ -0,0 +1,30 @@ +"use client"; + +import * as React from "react"; +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; + +import { cn } from "@sora-vp/ui"; + +const TooltipProvider = TooltipPrimitive.Provider; + +const Tooltip = TooltipPrimitive.Root; + +const TooltipTrigger = TooltipPrimitive.Trigger; + +const TooltipContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + +)); +TooltipContent.displayName = TooltipPrimitive.Content.displayName; + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/yarn.lock b/yarn.lock index 7228a7aa..4a13c6f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2128,6 +2128,37 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-tooltip@npm:^1.0.7": + version: 1.0.7 + resolution: "@radix-ui/react-tooltip@npm:1.0.7" + dependencies: + "@babel/runtime": "npm:^7.13.10" + "@radix-ui/primitive": "npm:1.0.1" + "@radix-ui/react-compose-refs": "npm:1.0.1" + "@radix-ui/react-context": "npm:1.0.1" + "@radix-ui/react-dismissable-layer": "npm:1.0.5" + "@radix-ui/react-id": "npm:1.0.1" + "@radix-ui/react-popper": "npm:1.1.3" + "@radix-ui/react-portal": "npm:1.0.4" + "@radix-ui/react-presence": "npm:1.0.1" + "@radix-ui/react-primitive": "npm:1.0.3" + "@radix-ui/react-slot": "npm:1.0.2" + "@radix-ui/react-use-controllable-state": "npm:1.0.1" + "@radix-ui/react-visually-hidden": "npm:1.0.3" + peerDependencies: + "@types/react": "*" + "@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 + checksum: 10c0/915524ea9d102eb26e656c550a084ca460219041c0e7cec0e72b522ee52a43b4d725f4ad3352212f4ae88b3672ef7b23bad07844275cafea075ada590678d873 + languageName: node + linkType: hard + "@radix-ui/react-use-callback-ref@npm:1.0.1": version: 1.0.1 resolution: "@radix-ui/react-use-callback-ref@npm:1.0.1" @@ -2237,6 +2268,26 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-visually-hidden@npm:1.0.3": + version: 1.0.3 + resolution: "@radix-ui/react-visually-hidden@npm:1.0.3" + dependencies: + "@babel/runtime": "npm:^7.13.10" + "@radix-ui/react-primitive": "npm:1.0.3" + peerDependencies: + "@types/react": "*" + "@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 + checksum: 10c0/0cbc12c2156b3fa0e40090cafd8525ce84c16a6b5a038a8e8fc7cbb16ed6da9ab369593962c57a18c41a16ec8713e0195c68ea34072ef1ca254ed4d4c0770bb4 + languageName: node + linkType: hard + "@radix-ui/rect@npm:1.0.1": version: 1.0.1 resolution: "@radix-ui/rect@npm:1.0.1" @@ -2720,6 +2771,7 @@ __metadata: "@radix-ui/react-label": "npm:^2.0.2" "@radix-ui/react-slot": "npm:^1.0.2" "@radix-ui/react-switch": "npm:^1.0.3" + "@radix-ui/react-tooltip": "npm:^1.0.7" "@sora-vp/eslint-config": "npm:*" "@sora-vp/prettier-config": "npm:*" "@sora-vp/tailwind-config": "npm:*" @@ -2783,6 +2835,7 @@ __metadata: eslint: "npm:^9.2.0" geist: "npm:^1.3.0" jiti: "npm:^1.21.0" + lucide-react: "npm:^0.390.0" next: "npm:^14.2.3" prettier: "npm:^3.2.5" react: "npm:18.3.1" @@ -8075,6 +8128,15 @@ __metadata: languageName: node linkType: hard +"lucide-react@npm:^0.390.0": + version: 0.390.0 + resolution: "lucide-react@npm:0.390.0" + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 + checksum: 10c0/d8062c311e703c592c0704d624855b965ba2caa3260f7158f65eb6a63c0826150ac67d35df210d58830bc6d85f8ad0e98e62c7e02e6f9e37690909aa046f64d0 + languageName: node + linkType: hard + "magic-string@npm:^0.30.0, magic-string@npm:^0.30.3, magic-string@npm:^0.30.5, magic-string@npm:^0.30.8": version: 0.30.10 resolution: "magic-string@npm:0.30.10" From 8878b854bd28cbce360eaae08890672d9e8ca49c Mon Sep 17 00:00:00 2001 From: Ezra Khairan Permana Date: Mon, 10 Jun 2024 17:04:39 +0700 Subject: [PATCH 19/44] feat: menambahkan fitur approve user --- apps/web/package.json | 1 + .../app/(auth)/admin/(adminRole)/layout.tsx | 13 + .../src/app/(auth)/admin/(adminRole)/page.tsx | 5 +- apps/web/src/app/(auth)/layout.tsx | 7 +- .../admin/pending-user/accept-user.tsx | 130 +++++++ .../_components/admin/pending-user/index.tsx | 329 ++++++++++++++++++ packages/api/src/root.ts | 2 + packages/api/src/router/admin.ts | 83 +++++ packages/api/src/trpc.ts | 22 ++ packages/ui/package.json | 2 + packages/ui/src/dialog.tsx | 122 +++++++ packages/ui/src/select.tsx | 164 +++++++++ packages/ui/src/skeleton.tsx | 15 + packages/ui/src/table.tsx | 120 +++++++ yarn.lock | 73 +++- 15 files changed, 1081 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/app/(auth)/admin/(adminRole)/layout.tsx create mode 100644 apps/web/src/app/_components/admin/pending-user/accept-user.tsx create mode 100644 apps/web/src/app/_components/admin/pending-user/index.tsx create mode 100644 packages/api/src/router/admin.ts create mode 100644 packages/ui/src/dialog.tsx create mode 100644 packages/ui/src/select.tsx create mode 100644 packages/ui/src/skeleton.tsx create mode 100644 packages/ui/src/table.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 973d52e1..6b97e676 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,6 +21,7 @@ "@sora-vp/validators": "*", "@t3-oss/env-nextjs": "^0.10.1", "@tanstack/react-query": "^5.35.1", + "@tanstack/react-table": "^8.17.3", "@trpc/client": "11.0.0-rc.364", "@trpc/react-query": "11.0.0-rc.364", "@trpc/server": "11.0.0-rc.364", diff --git a/apps/web/src/app/(auth)/admin/(adminRole)/layout.tsx b/apps/web/src/app/(auth)/admin/(adminRole)/layout.tsx new file mode 100644 index 00000000..b8a5745a --- /dev/null +++ b/apps/web/src/app/(auth)/admin/(adminRole)/layout.tsx @@ -0,0 +1,13 @@ +import { redirect } from "next/navigation"; + +import { auth } from "@sora-vp/auth"; + +export default async function RootLayout(props: { children: React.ReactNode }) { + const isLoggedIn = await auth(); + + if (!isLoggedIn) redirect("/login"); + + if (isLoggedIn.user.role !== "admin") redirect("/admin/partisipan"); + + return <>{props.children}; +} diff --git a/apps/web/src/app/(auth)/admin/(adminRole)/page.tsx b/apps/web/src/app/(auth)/admin/(adminRole)/page.tsx index c90f6979..679fb05a 100644 --- a/apps/web/src/app/(auth)/admin/(adminRole)/page.tsx +++ b/apps/web/src/app/(auth)/admin/(adminRole)/page.tsx @@ -1,8 +1,9 @@ +import { PendingUser } from "~/app/_components/admin/pending-user/index"; import { ToggleCanLogin } from "~/app/_components/admin/toggle-can-login"; export default function AdminPage() { return ( -
+

Beranda Admin

@@ -22,6 +23,8 @@ export default function AdminPage() {

Menunggu Persetujuan

+ +
); diff --git a/apps/web/src/app/(auth)/layout.tsx b/apps/web/src/app/(auth)/layout.tsx index 20f46312..ba920f52 100644 --- a/apps/web/src/app/(auth)/layout.tsx +++ b/apps/web/src/app/(auth)/layout.tsx @@ -66,7 +66,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) { -
+
{props.children}
-
- -
diff --git a/apps/web/src/app/_components/admin/pending-user/accept-user.tsx b/apps/web/src/app/_components/admin/pending-user/accept-user.tsx new file mode 100644 index 00000000..f70343ed --- /dev/null +++ b/apps/web/src/app/_components/admin/pending-user/accept-user.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Loader2 } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { Button } from "@sora-vp/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@sora-vp/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@sora-vp/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@sora-vp/ui/select"; + +const FormSchema = z.object({ + role: z.enum(["comittee", "admin"], { + required_error: "Dimohon untuk memilih tingkatan pengguna", + }), +}); + +export const AcceptUser = ({ + isOpen, + toggleOpen, + isDisabled, + isLoading, + onSubmit, +}: { + isOpen: boolean; + toggleOpen: () => void; + isDisabled: boolean; + isLoading: boolean; + onSubmit: (data: z.infer) => void; +}) => { + const form = useForm>({ + resolver: zodResolver(FormSchema), + }); + + return ( + { + if (!isLoading) toggleOpen(); + }} + > + + + + + + + Apakah anda yakin? + + Anda akan mengizinkan pengguna untuk mengakses platform enpitsu ini, + mohon berkomunikasi dengan orang yang bersangkutan. Jika benar maka + pilih tingkatan pengguna tersebut pada pilihan di bawah lalu{" "} + Izinkan. + + +
+ + ( + + Tingkatan Pengguna + + + + )} + /> + + +
+
+ + + + + + +
+
+ ); +}; diff --git a/apps/web/src/app/_components/admin/pending-user/index.tsx b/apps/web/src/app/_components/admin/pending-user/index.tsx new file mode 100644 index 00000000..ea50fe83 --- /dev/null +++ b/apps/web/src/app/_components/admin/pending-user/index.tsx @@ -0,0 +1,329 @@ +"use client"; + +import type { RouterOutputs } from "@enpitsu/api"; +import type { + ColumnDef, + ColumnFiltersState, + SortingState, +} from "@tanstack/react-table"; +import { useCallback, useState } from "react"; +import { + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { + ChevronLeftIcon, + ChevronRightIcon, + ChevronsLeft, + ChevronsRight, + Loader2, +} from "lucide-react"; + +import { Button } from "@sora-vp/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@sora-vp/ui/select"; +import { Skeleton } from "@sora-vp/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@sora-vp/ui/table"; +import { toast } from "@sora-vp/ui/toast"; + +import { api } from "~/trpc/react"; +import { AcceptUser } from "./accept-user"; + +type PendingUserList = RouterOutputs["admin"]["getPendingUser"][number]; + +export const columns: ColumnDef[] = [ + { + accessorKey: "user", + header: "Informasi Akun", + cell: ({ row }) => ( +
+

{row.original.name ? row.original.name : "N/A"}

+ + {row.original.email ? row.original.email : "N/A"} + +
+ ), + }, + { + id: "accept", + enableHiding: false, + cell: ({ row }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const apiUtils = api.useUtils(); + + // eslint-disable-next-line react-hooks/rules-of-hooks + const [isOpen, setOpen] = useState(false); + + // eslint-disable-next-line react-hooks/rules-of-hooks + const toggleOpen = useCallback(() => setOpen((prev) => !prev), []); + + const acceptUserMutation = api.admin.acceptPendingUser.useMutation({ + async onSuccess() { + toast.success("Berhasil menerima pengguna baru!", { + description: "Pengguna berhasil di approve.", + }); + + toggleOpen(); + + await apiUtils.admin.getAllRegisteredUser.invalidate(); + }, + onError(error) { + toast.error("Operasi Gagal", { + description: `Terjadi kesalahan, Error: ${error.message}`, + }); + }, + async onSettled() { + await apiUtils.admin.getPendingUser.invalidate(); + }, + }); + + // eslint-disable-next-line react-hooks/rules-of-hooks + const triggerAcceptCallback = useCallback( + (data: { role: "admin" | "comittee" }) => + acceptUserMutation.mutate({ id: row.original.id, role: data.role }), + [acceptUserMutation, row.original.id], + ); + + const rejectUserMutation = api.admin.rejectPendingUser.useMutation({ + onSuccess() { + toast.success("Berhasil menolak pengguna!", { + description: "Pengguna berhasil dihapus.", + }); + }, + onError(error) { + toast.error("Operasi Gagal", { + description: `Terjadi kesalahan, Error: ${error.message}`, + }); + }, + async onSettled() { + await apiUtils.admin.getPendingUser.invalidate(); + }, + }); + + return ( +
+ + + +
+ ); + }, + }, +]; + +export function PendingUser() { + const pendingUserQuery = api.admin.getPendingUser.useQuery(undefined); + + const [sorting, setSorting] = useState([]); + const [columnFilters, setColumnFilters] = useState([]); + const [rowSelection, setRowSelection] = useState({}); + + const table = useReactTable({ + data: pendingUserQuery.data ?? [], + columns, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onRowSelectionChange: setRowSelection, + initialState: { pagination: { pageSize: 20 } }, + state: { + sorting, + columnFilters, + rowSelection, + }, + }); + + return ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} + + ))} + + + {pendingUserQuery.isError ? ( + + + Error: {pendingUserQuery.error.message} + + + ) : null} + + {pendingUserQuery.isLoading && !pendingUserQuery.isError ? ( + <> + {Array.from({ length: 5 }).map((_, idx) => ( + + + + + + ))} + + ) : null} + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + <> + {!pendingUserQuery.isLoading && ( + <> + {!pendingUserQuery.isError && ( + + + Tidak ada data. + + + )} + + )} + + )} + +
+
+
+
+
+

Baris per halaman

+ +
+
+ Halaman {table.getState().pagination.pageIndex + 1} dari{" "} + {table.getPageCount()} +
+
+ + + + +
+
+
+
+ ); +} diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index 5c801ac3..ed40c5d0 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -1,3 +1,4 @@ +import { adminRouter } from "./router/admin"; import { authRouter } from "./router/auth"; import { settingsRouter } from "./router/settings"; import { createTRPCRouter } from "./trpc"; @@ -5,6 +6,7 @@ import { createTRPCRouter } from "./trpc"; export const appRouter = createTRPCRouter({ auth: authRouter, settings: settingsRouter, + admin: adminRouter, }); // export type definition of API diff --git a/packages/api/src/router/admin.ts b/packages/api/src/router/admin.ts new file mode 100644 index 00000000..ab74d015 --- /dev/null +++ b/packages/api/src/router/admin.ts @@ -0,0 +1,83 @@ +import type { TRPCRouterRecord } from "@trpc/server"; +import { z } from "zod"; + +import { and, eq, not, schema, sql } from "@sora-vp/db"; + +import { adminProcedure } from "../trpc"; + +export const adminRouter = { + // user approval and rejection start from here + getPendingUser: adminProcedure.query(({ ctx }) => + ctx.db.query.users.findMany({ + where: and( + sql`${schema.users.verifiedAt} IS NULL`, + not(eq(schema.users.email, ctx.session.user.email)), + ), + }), + ), + + rejectPendingUser: adminProcedure + .input(z.object({ id: z.number() })) + .mutation(({ ctx, input }) => + ctx.db.transaction(async (tx) => { + const specificUser = await tx.query.users.findFirst({ + where: eq(schema.users.id, input.id), + }); + + if (!specificUser) + throw new TRPCError({ + code: "NOT_FOUND", + message: "Pengguna yang dituju tidak ditemukan!", + }); + + if (specificUser.verifiedAt) + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Pengguna sudah di approve!", + }); + + await tx.delete(schema.users).where(eq(schema.users.id, input.id)); + }), + ), + + acceptPendingUser: adminProcedure + .input( + z.object({ + id: z.number(), + role: z.enum(["admin", "comittee"]), + }), + ) + .mutation(({ ctx, input }) => + ctx.db.transaction(async (tx) => { + const specificUser = await tx.query.users.findFirst({ + where: eq(schema.users.id, input.id), + }); + + if (!specificUser) + throw new TRPCError({ + code: "NOT_FOUND", + message: "Pengguna yang dituju tidak ditemukan!", + }); + + if (ctx.session.user.email === specificUser.email) + throw new TRPCError({ + code: "BAD_REQUEST", + message: "R u lost ur mind?", + }); + + if (specificUser.emailVerified) + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Pengguna sudah di approve!", + }); + + return await tx + .update(schema.users) + .set({ + verifiedAt: new Date(), + role: input.role, + }) + .where(eq(schema.users.id, input.id)); + }), + ), +} satisfies TRPCRouterRecord; diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index aa3bb45e..70aec613 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -96,7 +96,10 @@ export const publicProcedure = t.procedure; export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); + } else if (!ctx.session.user.verifiedAt) { + throw new TRPCError({ code: "UNAUTHORIZED" }); } + return next({ ctx: { // infers the `session` as non-nullable @@ -104,3 +107,22 @@ export const protectedProcedure = t.procedure.use(({ ctx, next }) => { }, }); }); + +const enforceUserIsAuthedAsAdmin = t.middleware(({ ctx, next }) => { + if (!ctx.session?.user) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } else if (!ctx.session.user.verifiedAt) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } else if (ctx.session.user.role !== "admin") { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + return next({ + ctx: { + // infers the `session` as non-nullable + session: { ...ctx.session, user: ctx.session.user }, + }, + }); +}); + +export const adminProcedure = t.procedure.use(enforceUserIsAuthedAsAdmin); diff --git a/packages/ui/package.json b/packages/ui/package.json index c359bbae..72ac1b69 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -20,9 +20,11 @@ "@hookform/resolvers": "^3.3.4", "@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-avatar": "^1.0.4", + "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-tooltip": "^1.0.7", diff --git a/packages/ui/src/dialog.tsx b/packages/ui/src/dialog.tsx new file mode 100644 index 00000000..bf995630 --- /dev/null +++ b/packages/ui/src/dialog.tsx @@ -0,0 +1,122 @@ +"use client"; + +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { Cross2Icon } from "@radix-ui/react-icons"; + +import { cn } from "@sora-vp/ui"; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/packages/ui/src/select.tsx b/packages/ui/src/select.tsx new file mode 100644 index 00000000..3e20bb0e --- /dev/null +++ b/packages/ui/src/select.tsx @@ -0,0 +1,164 @@ +"use client"; + +import * as React from "react"; +import { + CaretSortIcon, + CheckIcon, + ChevronDownIcon, + ChevronUpIcon, +} from "@radix-ui/react-icons"; +import * as SelectPrimitive from "@radix-ui/react-select"; + +import { cn } from "@sora-vp/ui"; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className, + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/packages/ui/src/skeleton.tsx b/packages/ui/src/skeleton.tsx new file mode 100644 index 00000000..02eeba08 --- /dev/null +++ b/packages/ui/src/skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from "@sora-vp/ui"; + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} + +export { Skeleton }; diff --git a/packages/ui/src/table.tsx b/packages/ui/src/table.tsx new file mode 100644 index 00000000..fa8e1dc9 --- /dev/null +++ b/packages/ui/src/table.tsx @@ -0,0 +1,120 @@ +import * as React from "react"; + +import { cn } from "@sora-vp/ui"; + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+ + +)); +Table.displayName = "Table"; + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableHeader.displayName = "TableHeader"; + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableBody.displayName = "TableBody"; + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", + className, + )} + {...props} + /> +)); +TableFooter.displayName = "TableFooter"; + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableRow.displayName = "TableRow"; + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
[role=checkbox]]:translate-y-[2px]", + className, + )} + {...props} + /> +)); +TableHead.displayName = "TableHead"; + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + [role=checkbox]]:translate-y-[2px]", + className, + )} + {...props} + /> +)); +TableCell.displayName = "TableCell"; + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableCaption.displayName = "TableCaption"; + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +}; diff --git a/yarn.lock b/yarn.lock index 4a13c6f3..c200d8af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1621,6 +1621,15 @@ __metadata: languageName: node linkType: hard +"@radix-ui/number@npm:1.0.1": + version: 1.0.1 + resolution: "@radix-ui/number@npm:1.0.1" + dependencies: + "@babel/runtime": "npm:^7.13.10" + checksum: 10c0/42e4870cd14459da6da03e43c7507dc4c807ed787a87bda52912a0d1d6d5013326b697c18c9625fc6a2cf0af2b45d9c86747985b45358fd92ab646b983978e3c + languageName: node + linkType: hard + "@radix-ui/primitive@npm:1.0.1": version: 1.0.1 resolution: "@radix-ui/primitive@npm:1.0.1" @@ -1751,7 +1760,7 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-dialog@npm:1.0.5": +"@radix-ui/react-dialog@npm:1.0.5, @radix-ui/react-dialog@npm:^1.0.5": version: 1.0.5 resolution: "@radix-ui/react-dialog@npm:1.0.5" dependencies: @@ -2086,6 +2095,46 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-select@npm:^2.0.0": + version: 2.0.0 + resolution: "@radix-ui/react-select@npm:2.0.0" + dependencies: + "@babel/runtime": "npm:^7.13.10" + "@radix-ui/number": "npm:1.0.1" + "@radix-ui/primitive": "npm:1.0.1" + "@radix-ui/react-collection": "npm:1.0.3" + "@radix-ui/react-compose-refs": "npm:1.0.1" + "@radix-ui/react-context": "npm:1.0.1" + "@radix-ui/react-direction": "npm:1.0.1" + "@radix-ui/react-dismissable-layer": "npm:1.0.5" + "@radix-ui/react-focus-guards": "npm:1.0.1" + "@radix-ui/react-focus-scope": "npm:1.0.4" + "@radix-ui/react-id": "npm:1.0.1" + "@radix-ui/react-popper": "npm:1.1.3" + "@radix-ui/react-portal": "npm:1.0.4" + "@radix-ui/react-primitive": "npm:1.0.3" + "@radix-ui/react-slot": "npm:1.0.2" + "@radix-ui/react-use-callback-ref": "npm:1.0.1" + "@radix-ui/react-use-controllable-state": "npm:1.0.1" + "@radix-ui/react-use-layout-effect": "npm:1.0.1" + "@radix-ui/react-use-previous": "npm:1.0.1" + "@radix-ui/react-visually-hidden": "npm:1.0.3" + aria-hidden: "npm:^1.1.1" + react-remove-scroll: "npm:2.5.5" + peerDependencies: + "@types/react": "*" + "@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 + checksum: 10c0/63aa4d119c5273035a2fce5a05739729abb8995ead00e810b86acfba05835fda655d962d3553b1f2011ed4f84e328f1e7e171cd9eaa7e3433b3d65c58cf3394a + languageName: node + linkType: hard + "@radix-ui/react-slot@npm:1.0.2, @radix-ui/react-slot@npm:^1.0.2": version: 1.0.2 resolution: "@radix-ui/react-slot@npm:1.0.2" @@ -2766,9 +2815,11 @@ __metadata: "@hookform/resolvers": "npm:^3.3.4" "@radix-ui/react-alert-dialog": "npm:^1.0.5" "@radix-ui/react-avatar": "npm:^1.0.4" + "@radix-ui/react-dialog": "npm:^1.0.5" "@radix-ui/react-dropdown-menu": "npm:^2.0.6" "@radix-ui/react-icons": "npm:^1.3.0" "@radix-ui/react-label": "npm:^2.0.2" + "@radix-ui/react-select": "npm:^2.0.0" "@radix-ui/react-slot": "npm:^1.0.2" "@radix-ui/react-switch": "npm:^1.0.3" "@radix-ui/react-tooltip": "npm:^1.0.7" @@ -2825,6 +2876,7 @@ __metadata: "@sora-vp/validators": "npm:*" "@t3-oss/env-nextjs": "npm:^0.10.1" "@tanstack/react-query": "npm:^5.35.1" + "@tanstack/react-table": "npm:^8.17.3" "@trpc/client": "npm:11.0.0-rc.364" "@trpc/react-query": "npm:11.0.0-rc.364" "@trpc/server": "npm:11.0.0-rc.364" @@ -2916,6 +2968,25 @@ __metadata: languageName: node linkType: hard +"@tanstack/react-table@npm:^8.17.3": + version: 8.17.3 + resolution: "@tanstack/react-table@npm:8.17.3" + dependencies: + "@tanstack/table-core": "npm:8.17.3" + peerDependencies: + react: ">=16.8" + react-dom: ">=16.8" + checksum: 10c0/d1ab321f189f1c7e20336c0516f8155a3d46aea74cc7ec849239b7323cf3e4fe919585489de3512eab36fceb7c4eae245e58235e6fcb5f112379411d801605fd + languageName: node + linkType: hard + +"@tanstack/table-core@npm:8.17.3": + version: 8.17.3 + resolution: "@tanstack/table-core@npm:8.17.3" + checksum: 10c0/e45f0f74b645689c762dd8c1042726d804726a488130fb2d36e24384bd813a601ed4d20abff3b4ba1bb1a648d14029f65d7fa8728099e0fd9f2702b51dc586c2 + languageName: node + linkType: hard + "@tootallnate/quickjs-emscripten@npm:^0.23.0": version: 0.23.0 resolution: "@tootallnate/quickjs-emscripten@npm:0.23.0" From 64768a3dcb0288f5ccb1ec3e3cc30950a66b9281 Mon Sep 17 00:00:00 2001 From: Ezra Khairan Permana Date: Mon, 10 Jun 2024 17:29:10 +0700 Subject: [PATCH 20/44] feat: menambahkan fungsi ubah role --- apps/web/package.json | 1 + .../src/app/(auth)/admin/(adminRole)/page.tsx | 3 + .../admin/all-registered-user/index.tsx | 325 ++++++++++++++++++ .../admin/all-registered-user/update-role.tsx | 159 +++++++++ .../admin/pending-user/accept-user.tsx | 6 +- .../_components/admin/pending-user/index.tsx | 2 +- packages/api/src/router/admin.ts | 43 +++ yarn.lock | 8 + 8 files changed, 543 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/app/_components/admin/all-registered-user/index.tsx create mode 100644 apps/web/src/app/_components/admin/all-registered-user/update-role.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 6b97e676..9416948c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -25,6 +25,7 @@ "@trpc/client": "11.0.0-rc.364", "@trpc/react-query": "11.0.0-rc.364", "@trpc/server": "11.0.0-rc.364", + "date-fns": "^3.6.0", "geist": "^1.3.0", "lucide-react": "^0.390.0", "next": "^14.2.3", diff --git a/apps/web/src/app/(auth)/admin/(adminRole)/page.tsx b/apps/web/src/app/(auth)/admin/(adminRole)/page.tsx index 679fb05a..3d91b9d4 100644 --- a/apps/web/src/app/(auth)/admin/(adminRole)/page.tsx +++ b/apps/web/src/app/(auth)/admin/(adminRole)/page.tsx @@ -1,3 +1,4 @@ +import { AllRegisteredUser } from "~/app/_components/admin/all-registered-user/index"; import { PendingUser } from "~/app/_components/admin/pending-user/index"; import { ToggleCanLogin } from "~/app/_components/admin/toggle-can-login"; @@ -17,6 +18,8 @@ export default function AdminPage() {

Seluruh Pengguna

+ +
diff --git a/apps/web/src/app/_components/admin/all-registered-user/index.tsx b/apps/web/src/app/_components/admin/all-registered-user/index.tsx new file mode 100644 index 00000000..8fb0fb8b --- /dev/null +++ b/apps/web/src/app/_components/admin/all-registered-user/index.tsx @@ -0,0 +1,325 @@ +"use client"; + +import type { RouterOutputs } from "@sora-vp/api"; +import type { + ColumnDef, + ColumnFiltersState, + SortingState, +} from "@tanstack/react-table"; +import { useCallback, useState } from "react"; +import { Space_Mono } from "next/font/google"; +import { + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { format } from "date-fns"; +import { id } from "date-fns/locale"; +import { + ChevronLeftIcon, + ChevronRightIcon, + ChevronsLeft, + ChevronsRight, + MoreHorizontal, + PencilLine, +} from "lucide-react"; + +import { Button } from "@sora-vp/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@sora-vp/ui/dropdown-menu"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@sora-vp/ui/select"; +import { Skeleton } from "@sora-vp/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@sora-vp/ui/table"; + +import { api } from "~/trpc/react"; +import { UpdateRole } from "./update-role"; + +type PendingUserList = RouterOutputs["admin"]["getAllRegisteredUser"][number]; + +const MonoFont = Space_Mono({ + weight: "400", + subsets: ["latin"], +}); + +export const columns: ColumnDef[] = [ + { + accessorKey: "user", + header: "Informasi Akun", + cell: ({ row }) => ( +
+
+

{row.original.name ? row.original.name : "N/A"}

+ + {row.original.email ? row.original.email : "N/A"} + +
+
+ ), + }, + { + accessorKey: "role", + header: "Tingkatan Pengguna", + cell: ({ row }) => ( +

+ {row.getValue("role") === "admin" ? "Administrator" : "Panitia Biasa"} +

+ ), + }, + { + accessorKey: "verifiedAt", + header: "Waktu Pengguna Terverifikasi", + cell: ({ row }) => ( +
+        {format(row.getValue("verifiedAt"), "dd MMMM yyyy, kk.mm", {
+          locale: id,
+        })}
+      
+ ), + }, + { + id: "actions", + enableHiding: false, + cell: ({ row }) => { + const user = row.original; + + // eslint-disable-next-line react-hooks/rules-of-hooks + const [openUpdate, setOpenUpdate] = useState(false); + + // eslint-disable-next-line react-hooks/rules-of-hooks + const toggleOpen = useCallback(() => setOpenUpdate((prev) => !prev), []); + + return ( + <> + + + + + + Aksi + setOpenUpdate(true)} + > + + Perbarui Tingkatan + + + + + + + ); + }, + }, +]; + +export function AllRegisteredUser() { + const allRegisteredUserQuery = + api.admin.getAllRegisteredUser.useQuery(undefined); + + const [sorting, setSorting] = useState([]); + const [columnFilters, setColumnFilters] = useState([]); + const [rowSelection, setRowSelection] = useState({}); + + const table = useReactTable({ + data: allRegisteredUserQuery.data ?? [], + columns, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onRowSelectionChange: setRowSelection, + initialState: { pagination: { pageSize: 20 } }, + state: { + sorting, + columnFilters, + rowSelection, + }, + }); + + return ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} + + ))} + + + {allRegisteredUserQuery.isError ? ( + + + Error: {allRegisteredUserQuery.error.message} + + + ) : null} + + {allRegisteredUserQuery.isLoading && + !allRegisteredUserQuery.isError ? ( + <> + {Array.from({ length: 5 }).map((_, idx) => ( + + + + + + ))} + + ) : null} + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + <> + {!allRegisteredUserQuery.isLoading && ( + <> + {!allRegisteredUserQuery.isError && ( + + + Tidak ada data. + + + )} + + )} + + )} + +
+
+
+
+
+

Baris per halaman

+ +
+
+ Halaman {table.getState().pagination.pageIndex + 1} dari{" "} + {table.getPageCount()} +
+
+ + + + +
+
+
+
+ ); +} diff --git a/apps/web/src/app/_components/admin/all-registered-user/update-role.tsx b/apps/web/src/app/_components/admin/all-registered-user/update-role.tsx new file mode 100644 index 00000000..c5141020 --- /dev/null +++ b/apps/web/src/app/_components/admin/all-registered-user/update-role.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Loader2 } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { Button } from "@sora-vp/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@sora-vp/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@sora-vp/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@sora-vp/ui/select"; +import { toast } from "@sora-vp/ui/toast"; + +import { api } from "~/trpc/react"; + +const FormSchema = z.object({ + role: z.enum(["admin", "comittee"], { + required_error: "Dimohon untuk memilih tingkatan pengguna", + }), +}); + +export const UpdateRole = ({ + isOpen, + currRole, + userId, + toggleOpen, +}: { + isOpen: boolean; + currRole: "admin" | "comittee"; + userId: number; + toggleOpen: () => void; +}) => { + const utils = api.useUtils(); + const updateRoleMutation = api.admin.updateUserRole.useMutation({ + onSuccess() { + toast.success("Berhasil memperbarui pengguna!", { + description: "Status pengguna berhasil diperbarui.", + }); + toggleOpen(); + }, + onError(error) { + toast.error("Operasi Gagal", { + description: `Terjadi kesalahan, Error: ${error.message}`, + }); + }, + async onSettled() { + await utils.admin.getAllRegisteredUser.invalidate(); + }, + }); + + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: { + role: currRole, + }, + }); + + const onSubmit = (data: z.infer) => + updateRoleMutation.mutate({ id: userId, ...data }); + + return ( + { + if (!updateRoleMutation.isPending) toggleOpen(); + }} + > + + + Perbarui tingkatan pengguna + + Anda akan memperbarui tingkatan pengguna ini. Mohon pikirkan kembali + dan cek apakah dia adalah orang yang benar dan pantas di ubah + tingkatannya supaya tidak menimbulkan keributan. + + +
+ + ( + + Tingkatan Pengguna + + + + )} + /> + + +
+
+ + + + + + +
+
+ ); +}; diff --git a/apps/web/src/app/_components/admin/pending-user/accept-user.tsx b/apps/web/src/app/_components/admin/pending-user/accept-user.tsx index f70343ed..bf29774d 100644 --- a/apps/web/src/app/_components/admin/pending-user/accept-user.tsx +++ b/apps/web/src/app/_components/admin/pending-user/accept-user.tsx @@ -70,9 +70,9 @@ export const AcceptUser = ({ Apakah anda yakin? - Anda akan mengizinkan pengguna untuk mengakses platform enpitsu ini, - mohon berkomunikasi dengan orang yang bersangkutan. Jika benar maka - pilih tingkatan pengguna tersebut pada pilihan di bawah lalu{" "} + Anda akan mengizinkan pengguna untuk mengakses platform ini, mohon + berkomunikasi dengan orang yang bersangkutan. Jika benar maka pilih + tingkatan pengguna tersebut pada pilihan di bawah lalu{" "} Izinkan. diff --git a/apps/web/src/app/_components/admin/pending-user/index.tsx b/apps/web/src/app/_components/admin/pending-user/index.tsx index ea50fe83..25a49d3a 100644 --- a/apps/web/src/app/_components/admin/pending-user/index.tsx +++ b/apps/web/src/app/_components/admin/pending-user/index.tsx @@ -1,6 +1,6 @@ "use client"; -import type { RouterOutputs } from "@enpitsu/api"; +import type { RouterOutputs } from "@sora-vp/api"; import type { ColumnDef, ColumnFiltersState, diff --git a/packages/api/src/router/admin.ts b/packages/api/src/router/admin.ts index ab74d015..9e628600 100644 --- a/packages/api/src/router/admin.ts +++ b/packages/api/src/router/admin.ts @@ -80,4 +80,47 @@ export const adminRouter = { .where(eq(schema.users.id, input.id)); }), ), + + updateUserRole: adminProcedure + .input( + z.object({ + id: z.number(), + role: z.enum(["admin", "comittee"]), + }), + ) + .mutation(({ ctx, input }) => + ctx.db.transaction(async (tx) => { + const specificUser = await tx.query.users.findFirst({ + where: eq(schema.users.id, input.id), + }); + + if (!specificUser) + throw new TRPCError({ + code: "NOT_FOUND", + message: "Pengguna yang dituju tidak ditemukan!", + }); + + if (ctx.session.user.email === specificUser.email) + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Mana bisa begitu? Update role sendiri?", + }); + + return await tx + .update(schema.users) + .set({ + role: input.role, + }) + .where(eq(schema.users.id, input.id)); + }), + ), + + getAllRegisteredUser: adminProcedure.query(({ ctx }) => + ctx.db.query.users.findMany({ + where: and( + sql`${schema.users.verifiedAt} IS NOT NULL`, + not(eq(schema.users.email, ctx.session.user.email)), + ), + }), + ), } satisfies TRPCRouterRecord; diff --git a/yarn.lock b/yarn.lock index c200d8af..e7db5fee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2883,6 +2883,7 @@ __metadata: "@types/node": "npm:^20.12.9" "@types/react": "npm:^18.3.1" "@types/react-dom": "npm:^18.3.0" + date-fns: "npm:^3.6.0" dotenv-cli: "npm:^7.4.1" eslint: "npm:^9.2.0" geist: "npm:^1.3.0" @@ -4835,6 +4836,13 @@ __metadata: languageName: node linkType: hard +"date-fns@npm:^3.6.0": + version: 3.6.0 + resolution: "date-fns@npm:3.6.0" + checksum: 10c0/0b5fb981590ef2f8e5a3ba6cd6d77faece0ea7f7158948f2eaae7bbb7c80a8f63ae30b01236c2923cf89bb3719c33aeb150c715ea4fe4e86e37dcf06bed42fb6 + languageName: node + linkType: hard + "dateformat@npm:^4.6.3": version: 4.6.3 resolution: "dateformat@npm:4.6.3" From 2ae63b8b22ff486a2d48db9fb717f970332d92b2 Mon Sep 17 00:00:00 2001 From: Ezra Khairan Permana Date: Tue, 11 Jun 2024 06:54:07 +0700 Subject: [PATCH 21/44] feat: persiapan halaman pengaturan --- .../src/app/(auth)/admin/(adminRole)/page.tsx | 2 +- .../admin/(adminRole)/pengaturan/page.tsx | 32 ++++++++++++++++++- apps/web/src/app/(auth)/layout.tsx | 4 +-- .../_components/admin/pending-user/index.tsx | 2 +- .../app/_components/nav/resizeable-nav.tsx | 4 +++ 5 files changed, 39 insertions(+), 5 deletions(-) diff --git a/apps/web/src/app/(auth)/admin/(adminRole)/page.tsx b/apps/web/src/app/(auth)/admin/(adminRole)/page.tsx index 3d91b9d4..a05246be 100644 --- a/apps/web/src/app/(auth)/admin/(adminRole)/page.tsx +++ b/apps/web/src/app/(auth)/admin/(adminRole)/page.tsx @@ -4,7 +4,7 @@ import { ToggleCanLogin } from "~/app/_components/admin/toggle-can-login"; export default function AdminPage() { return ( -
+

Beranda Admin

diff --git a/apps/web/src/app/(auth)/admin/(adminRole)/pengaturan/page.tsx b/apps/web/src/app/(auth)/admin/(adminRole)/pengaturan/page.tsx index fe25cb52..f71d66d2 100644 --- a/apps/web/src/app/(auth)/admin/(adminRole)/pengaturan/page.tsx +++ b/apps/web/src/app/(auth)/admin/(adminRole)/pengaturan/page.tsx @@ -1,3 +1,33 @@ +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@sora-vp/ui/card"; + export default function SettingsPage() { - return <>; + return ( +

+ + + Perilaku Pemilihan + + Atur perilaku pemilihan dengan mengubah switch di bawah. + + + + + + + + Waktu Pemilihan + + Tetapkan kapan lama durasi pemilihan ini berlangsung. + + + + +
+ ); } diff --git a/apps/web/src/app/(auth)/layout.tsx b/apps/web/src/app/(auth)/layout.tsx index ba920f52..d6cc47e7 100644 --- a/apps/web/src/app/(auth)/layout.tsx +++ b/apps/web/src/app/(auth)/layout.tsx @@ -5,7 +5,7 @@ import { GeistSans } from "geist/font/sans"; import { auth } from "@sora-vp/auth"; import { cn } from "@sora-vp/ui"; -import { ThemeProvider, ThemeToggle } from "@sora-vp/ui/theme"; +import { ThemeProvider } from "@sora-vp/ui/theme"; import { Toaster } from "@sora-vp/ui/toast"; import { TRPCReactProvider } from "~/trpc/react"; @@ -78,7 +78,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) { nameFallback={isLoggedIn.user.name?.slice(0, 2) ?? ""} role={isLoggedIn.user.role} > -
+
{props.children}
diff --git a/apps/web/src/app/_components/admin/pending-user/index.tsx b/apps/web/src/app/_components/admin/pending-user/index.tsx index 25a49d3a..8f4ae924 100644 --- a/apps/web/src/app/_components/admin/pending-user/index.tsx +++ b/apps/web/src/app/_components/admin/pending-user/index.tsx @@ -174,7 +174,7 @@ export function PendingUser() { }); return ( -
+
diff --git a/apps/web/src/app/_components/nav/resizeable-nav.tsx b/apps/web/src/app/_components/nav/resizeable-nav.tsx index 5a212e94..1477169d 100644 --- a/apps/web/src/app/_components/nav/resizeable-nav.tsx +++ b/apps/web/src/app/_components/nav/resizeable-nav.tsx @@ -10,6 +10,7 @@ import { ResizablePanel, ResizablePanelGroup, } from "@sora-vp/ui/resizable"; +import { ThemeToggle } from "@sora-vp/ui/theme"; import { TooltipProvider } from "@sora-vp/ui/tooltip"; import type { Props as TopNavbarProps } from "./top-navbar"; @@ -94,6 +95,9 @@ export function ResizeableNav({ links={role === "admin" ? adminNav : [participantNav]} /> +
+ +
From eaf0d3cfe77da904e693ce5a849055b2aec06ec0 Mon Sep 17 00:00:00 2001 From: Ezra Khairan Permana Date: Tue, 11 Jun 2024 08:46:09 +0700 Subject: [PATCH 22/44] feat: menambahkan fungsi atur perilaku --- .../admin/(adminRole)/pengaturan/page.tsx | 11 +- apps/web/src/app/(auth)/layout.tsx | 2 +- .../admin/all-registered-user/update-role.tsx | 13 +- .../admin/pending-user/accept-user.tsx | 13 +- .../_components/admin/toggle-can-login.tsx | 12 +- .../app/_components/settings/behaviour.tsx | 125 ++++++++++++++++++ .../src/app/_components/settings/duration.tsx | 13 ++ packages/api/src/router/admin.ts | 18 +-- packages/api/src/router/settings.ts | 29 +++- packages/settings/src/SettingsManager.ts | 1 + packages/validators/src/admin.ts | 25 ++++ packages/validators/src/index.ts | 2 + packages/validators/src/settings.ts | 17 +++ 13 files changed, 236 insertions(+), 45 deletions(-) create mode 100644 apps/web/src/app/_components/settings/behaviour.tsx create mode 100644 apps/web/src/app/_components/settings/duration.tsx create mode 100644 packages/validators/src/admin.ts create mode 100644 packages/validators/src/settings.ts diff --git a/apps/web/src/app/(auth)/admin/(adminRole)/pengaturan/page.tsx b/apps/web/src/app/(auth)/admin/(adminRole)/pengaturan/page.tsx index f71d66d2..35fa6b1d 100644 --- a/apps/web/src/app/(auth)/admin/(adminRole)/pengaturan/page.tsx +++ b/apps/web/src/app/(auth)/admin/(adminRole)/pengaturan/page.tsx @@ -6,6 +6,9 @@ import { CardTitle, } from "@sora-vp/ui/card"; +import { Behaviour } from "~/app/_components/settings/behaviour"; +import { Duration } from "~/app/_components/settings/duration"; + export default function SettingsPage() { return (
@@ -16,7 +19,9 @@ export default function SettingsPage() { Atur perilaku pemilihan dengan mengubah switch di bawah. - + + + @@ -26,7 +31,9 @@ export default function SettingsPage() { Tetapkan kapan lama durasi pemilihan ini berlangsung. - + + +
); diff --git a/apps/web/src/app/(auth)/layout.tsx b/apps/web/src/app/(auth)/layout.tsx index d6cc47e7..dcaf9242 100644 --- a/apps/web/src/app/(auth)/layout.tsx +++ b/apps/web/src/app/(auth)/layout.tsx @@ -5,7 +5,7 @@ import { GeistSans } from "geist/font/sans"; import { auth } from "@sora-vp/auth"; import { cn } from "@sora-vp/ui"; -import { ThemeProvider } from "@sora-vp/ui/theme"; +import { ThemeProvider, ThemeToggle } from "@sora-vp/ui/theme"; import { Toaster } from "@sora-vp/ui/toast"; import { TRPCReactProvider } from "~/trpc/react"; diff --git a/apps/web/src/app/_components/admin/all-registered-user/update-role.tsx b/apps/web/src/app/_components/admin/all-registered-user/update-role.tsx index c5141020..0d359988 100644 --- a/apps/web/src/app/_components/admin/all-registered-user/update-role.tsx +++ b/apps/web/src/app/_components/admin/all-registered-user/update-role.tsx @@ -31,14 +31,11 @@ import { SelectValue, } from "@sora-vp/ui/select"; import { toast } from "@sora-vp/ui/toast"; +import { admin } from "@sora-vp/validators"; import { api } from "~/trpc/react"; -const FormSchema = z.object({ - role: z.enum(["admin", "comittee"], { - required_error: "Dimohon untuk memilih tingkatan pengguna", - }), -}); +type FormSchema = z.infer; export const UpdateRole = ({ isOpen, @@ -69,14 +66,14 @@ export const UpdateRole = ({ }, }); - const form = useForm>({ - resolver: zodResolver(FormSchema), + const form = useForm({ + resolver: zodResolver(admin.RoleFormSchema), defaultValues: { role: currRole, }, }); - const onSubmit = (data: z.infer) => + const onSubmit = (data: FormSchema) => updateRoleMutation.mutate({ id: userId, ...data }); return ( diff --git a/apps/web/src/app/_components/admin/pending-user/accept-user.tsx b/apps/web/src/app/_components/admin/pending-user/accept-user.tsx index bf29774d..00d792ad 100644 --- a/apps/web/src/app/_components/admin/pending-user/accept-user.tsx +++ b/apps/web/src/app/_components/admin/pending-user/accept-user.tsx @@ -31,12 +31,9 @@ import { SelectTrigger, SelectValue, } from "@sora-vp/ui/select"; +import { admin } from "@sora-vp/validators"; -const FormSchema = z.object({ - role: z.enum(["comittee", "admin"], { - required_error: "Dimohon untuk memilih tingkatan pengguna", - }), -}); +type FormSchema = z.infer; export const AcceptUser = ({ isOpen, @@ -49,10 +46,10 @@ export const AcceptUser = ({ toggleOpen: () => void; isDisabled: boolean; isLoading: boolean; - onSubmit: (data: z.infer) => void; + onSubmit: (data: FormSchema) => void; }) => { - const form = useForm>({ - resolver: zodResolver(FormSchema), + const form = useForm({ + resolver: zodResolver(admin.RoleFormSchema), }); return ( diff --git a/apps/web/src/app/_components/admin/toggle-can-login.tsx b/apps/web/src/app/_components/admin/toggle-can-login.tsx index 8d57f438..0aa44dcc 100644 --- a/apps/web/src/app/_components/admin/toggle-can-login.tsx +++ b/apps/web/src/app/_components/admin/toggle-can-login.tsx @@ -15,17 +15,16 @@ import { } from "@sora-vp/ui/form"; import { Switch } from "@sora-vp/ui/switch"; import { toast } from "@sora-vp/ui/toast"; +import { settings } from "@sora-vp/validators"; import { api } from "~/trpc/react"; -const FormSchema = z.object({ - canLogin: z.boolean(), -}); +type FormSchema = z.infer; export const ToggleCanLogin = () => { const utils = api.useUtils(); - const form = useForm>({ - resolver: zodResolver(FormSchema), + const form = useForm({ + resolver: zodResolver(settings.SharedCanLogin), }); const canLoginQuery = api.settings.getCanLoginStatus.useQuery(); @@ -58,8 +57,7 @@ export const ToggleCanLogin = () => { }, }); - const onSubmit = (data: z.infer) => - canLoginMutation.mutate(data); + const onSubmit = (data: FormSchema) => canLoginMutation.mutate(data); return (
diff --git a/apps/web/src/app/_components/settings/behaviour.tsx b/apps/web/src/app/_components/settings/behaviour.tsx new file mode 100644 index 00000000..d57c5ce9 --- /dev/null +++ b/apps/web/src/app/_components/settings/behaviour.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { useEffect } from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { Button } from "@sora-vp/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@sora-vp/ui/form"; +import { Switch } from "@sora-vp/ui/switch"; +import { toast } from "@sora-vp/ui/toast"; +import { settings } from "@sora-vp/validators"; + +import { api } from "~/trpc/react"; + +type FormSchema = z.infer; + +export function Behaviour() { + const apiUtils = api.useUtils(); + + const settingsQuery = api.settings.getSettings.useQuery(); + + const form = useForm({ + resolver: zodResolver(settings.SharedBehaviour), + defaultValues: { + canVote: false, + canAttend: false, + }, + }); + + const changeBehaviour = api.settings.changeVotingBehaviour.useMutation({ + async onMutate() { + await apiUtils.settings.getSettings.cancel(); + }, + onSuccess() { + toast.success("Pengaturan perilaku pemilihan berhasil diperbarui!"); + }, + onError() { + toast.error( + "Gagal memperbarui pengaturan perilaku, mohon coba lagi nanti.", + ); + }, + async onSettled() { + await apiUtils.settings.getSettings.invalidate(); + }, + }); + + useEffect(() => { + if (settingsQuery.data && !changeBehaviour.isPending) { + form.setValue("canVote", settingsQuery.data.canVote); + form.setValue("canAttend", settingsQuery.data.canAttend); + } + }, [settingsQuery.data, changeBehaviour.isPending]); + + return ( + + changeBehaviour.mutate(data))} + className="space-y-4" + > + ( + +
+ Sudah Bisa Memilih + + Mengatur apakah sudah bisa memilih atau belum. + +
+ + + +
+ )} + /> + + ( + +
+ Sudah Bisa Absen + + Mengatur apakah sudah bisa absen atau belum. + +
+ + + +
+ )} + /> + + + + ); +} diff --git a/apps/web/src/app/_components/settings/duration.tsx b/apps/web/src/app/_components/settings/duration.tsx new file mode 100644 index 00000000..a8267101 --- /dev/null +++ b/apps/web/src/app/_components/settings/duration.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { settings } from "@sora-vp/validators"; + +type FormSchema = z.infer; + +export function Duration() { + return <>; +} diff --git a/packages/api/src/router/admin.ts b/packages/api/src/router/admin.ts index 9e628600..51f6297f 100644 --- a/packages/api/src/router/admin.ts +++ b/packages/api/src/router/admin.ts @@ -1,7 +1,7 @@ import type { TRPCRouterRecord } from "@trpc/server"; -import { z } from "zod"; import { and, eq, not, schema, sql } from "@sora-vp/db"; +import { admin } from "@sora-vp/validators"; import { adminProcedure } from "../trpc"; @@ -17,7 +17,7 @@ export const adminRouter = { ), rejectPendingUser: adminProcedure - .input(z.object({ id: z.number() })) + .input(admin.ServerAcceptObjectIdNumber) .mutation(({ ctx, input }) => ctx.db.transaction(async (tx) => { const specificUser = await tx.query.users.findFirst({ @@ -41,12 +41,7 @@ export const adminRouter = { ), acceptPendingUser: adminProcedure - .input( - z.object({ - id: z.number(), - role: z.enum(["admin", "comittee"]), - }), - ) + .input(admin.ServerAcceptIdAndRole) .mutation(({ ctx, input }) => ctx.db.transaction(async (tx) => { const specificUser = await tx.query.users.findFirst({ @@ -82,12 +77,7 @@ export const adminRouter = { ), updateUserRole: adminProcedure - .input( - z.object({ - id: z.number(), - role: z.enum(["admin", "comittee"]), - }), - ) + .input(admin.ServerAcceptIdAndRole) .mutation(({ ctx, input }) => ctx.db.transaction(async (tx) => { const specificUser = await tx.query.users.findFirst({ diff --git a/packages/api/src/router/settings.ts b/packages/api/src/router/settings.ts index fb625863..c2878b29 100644 --- a/packages/api/src/router/settings.ts +++ b/packages/api/src/router/settings.ts @@ -1,22 +1,41 @@ import type { TRPCRouterRecord } from "@trpc/server"; -import { z } from "zod"; import settings from "@sora-vp/settings"; +import { settings as settingsSchema } from "@sora-vp/validators"; -import { protectedProcedure, publicProcedure } from "../trpc"; +import { adminProcedure, publicProcedure } from "../trpc"; export const settingsRouter = { getSettings: publicProcedure.query(() => settings.getSettings()), - getCanLoginStatus: protectedProcedure.query(() => { + getCanLoginStatus: adminProcedure.query(() => { const { canLogin } = settings.getSettings(); return { canLogin }; }), - updateCanLogin: protectedProcedure - .input(z.object({ canLogin: z.boolean() })) + updateCanLogin: adminProcedure + .input(settingsSchema.SharedCanLogin) .mutation(async ({ input }) => settings.updateSettings.canLogin(input.canLogin), ), + + changeVotingBehaviour: adminProcedure + .input(settingsSchema.SharedBehaviour) + .mutation(({ input }) => { + settings.updateSettings.canVote(input.canVote); + settings.updateSettings.canAttend(input.canAttend); + + return { success: true }; + }), + + // changeVotingTime: adminProcedure + // // .input(ServerPengaturanWaktuValidationSchema) + // .input() + // .mutation(({ input }) => { + // settings.updateSettings.startTime(input.startTime); + // settings.updateSettings.endTime(input.endTime); + // + // return { success: true } + // }), } satisfies TRPCRouterRecord; diff --git a/packages/settings/src/SettingsManager.ts b/packages/settings/src/SettingsManager.ts index 2bbc72f5..5fc1d75f 100644 --- a/packages/settings/src/SettingsManager.ts +++ b/packages/settings/src/SettingsManager.ts @@ -45,6 +45,7 @@ export class SettingsManager extends EventEmitter { startTime: startTime ?? null, endTime: endTime ?? null, canVote: canVote ?? false, + canAttend: canAttend ?? false, canLogin: canLogin ?? true, }; } diff --git a/packages/validators/src/admin.ts b/packages/validators/src/admin.ts new file mode 100644 index 00000000..0f96c91e --- /dev/null +++ b/packages/validators/src/admin.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; + +const id = z.number().min(1).int(); +const role = z.enum(["admin", "comittee"], { + required_error: "Dimohon untuk memilih tingkatan pengguna", +}); + +const ServerAcceptObjectIdNumber = z.object({ + id, +}); + +const ServerAcceptIdAndRole = z.object({ + id, + role, +}); + +const RoleFormSchema = z.object({ + role, +}); + +export const admin = { + ServerAcceptObjectIdNumber, + ServerAcceptIdAndRole, + RoleFormSchema, +} as const; diff --git a/packages/validators/src/index.ts b/packages/validators/src/index.ts index 97ccf764..97a5e24b 100644 --- a/packages/validators/src/index.ts +++ b/packages/validators/src/index.ts @@ -1 +1,3 @@ export * from "./auth"; +export * from "./admin"; +export * from "./settings"; diff --git a/packages/validators/src/settings.ts b/packages/validators/src/settings.ts new file mode 100644 index 00000000..0ef6bb30 --- /dev/null +++ b/packages/validators/src/settings.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +const canLogin = z.boolean(); + +const SharedCanLogin = z.object({ + canLogin, +}); + +const SharedBehaviour = z.object({ + canVote: z.boolean(), + canAttend: z.boolean(), +}); + +export const settings = { + SharedCanLogin, + SharedBehaviour, +} as const; From 9e64c6e04233f5277087d09da2c880d4d87e5b9e Mon Sep 17 00:00:00 2001 From: Ezra Khairan Permana Date: Tue, 11 Jun 2024 09:54:35 +0700 Subject: [PATCH 23/44] feat: menambahkan fungsionalitas pengaturan durasi --- .../admin/(adminRole)/pengaturan/page.tsx | 48 +++--- .../app/_components/settings/behaviour.tsx | 4 +- .../src/app/_components/settings/duration.tsx | 146 +++++++++++++++++- packages/api/src/router/settings.ts | 17 +- packages/validators/src/settings.ts | 32 ++++ 5 files changed, 211 insertions(+), 36 deletions(-) diff --git a/apps/web/src/app/(auth)/admin/(adminRole)/pengaturan/page.tsx b/apps/web/src/app/(auth)/admin/(adminRole)/pengaturan/page.tsx index 35fa6b1d..a51ef4c1 100644 --- a/apps/web/src/app/(auth)/admin/(adminRole)/pengaturan/page.tsx +++ b/apps/web/src/app/(auth)/admin/(adminRole)/pengaturan/page.tsx @@ -11,30 +11,32 @@ import { Duration } from "~/app/_components/settings/duration"; export default function SettingsPage() { return ( -
- - - Perilaku Pemilihan - - Atur perilaku pemilihan dengan mengubah switch di bawah. - - - - - - +
+
+ + + Perilaku Pemilihan + + Atur perilaku pemilihan dengan mengubah switch di bawah. + + + + + + - - - Waktu Pemilihan - - Tetapkan kapan lama durasi pemilihan ini berlangsung. - - - - - - + + + Waktu Pemilihan + + Tetapkan kapan lama durasi pemilihan ini berlangsung. + + + + + + +
); } diff --git a/apps/web/src/app/_components/settings/behaviour.tsx b/apps/web/src/app/_components/settings/behaviour.tsx index d57c5ce9..179e98f8 100644 --- a/apps/web/src/app/_components/settings/behaviour.tsx +++ b/apps/web/src/app/_components/settings/behaviour.tsx @@ -55,8 +55,8 @@ export function Behaviour() { useEffect(() => { if (settingsQuery.data && !changeBehaviour.isPending) { - form.setValue("canVote", settingsQuery.data.canVote); - form.setValue("canAttend", settingsQuery.data.canAttend); + form.setValue("canVote", settingsQuery.data.canVote ?? undefined); + form.setValue("canAttend", settingsQuery.data.canAttend ?? undefined); } }, [settingsQuery.data, changeBehaviour.isPending]); diff --git a/apps/web/src/app/_components/settings/duration.tsx b/apps/web/src/app/_components/settings/duration.tsx index a8267101..531e24b4 100644 --- a/apps/web/src/app/_components/settings/duration.tsx +++ b/apps/web/src/app/_components/settings/duration.tsx @@ -1,13 +1,155 @@ "use client"; +import { useEffect } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; +import { format, startOfDay } from "date-fns"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import { Button } from "@sora-vp/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@sora-vp/ui/form"; +import { Input } from "@sora-vp/ui/input"; +import { Skeleton } from "@sora-vp/ui/skeleton"; +import { toast } from "@sora-vp/ui/toast"; import { settings } from "@sora-vp/validators"; -type FormSchema = z.infer; +import { api } from "~/trpc/react"; + +type FormSchema = z.infer; export function Duration() { - return <>; + const apiUtils = api.useUtils(); + + const settingsQuery = api.settings.getSettings.useQuery(); + + const form = useForm({ + resolver: zodResolver(settings.SharedDuration), + }); + + const changeDuration = api.settings.changeVotingTime.useMutation({ + async onMutate() { + await apiUtils.settings.getSettings.cancel(); + }, + onSuccess() { + toast.success("Pengaturan durasi pemilihan berhasil diperbarui!"); + }, + onError() { + toast.error( + "Gagal memperbarui pengaturan durasi, mohon coba lagi nanti.", + ); + }, + async onSettled() { + await apiUtils.settings.getSettings.invalidate(); + }, + }); + + useEffect(() => { + if (settingsQuery.data && !changeDuration.isPending) { + form.setValue("startTime", settingsQuery.data.startTime); + form.setValue("endTime", settingsQuery.data.endTime); + } + }, [settingsQuery.data, changeDuration.isPending]); + + return ( +
+ changeDuration.mutate(data))} + className="space-y-5" + > + ( + + Waktu Mulai + + {settingsQuery.isLoading ? ( + + ) : ( + + e.target.value === "" + ? field.onChange(undefined) + : field.onChange(new Date(e.target.value)) + } + disabled={changeDuration.isPending} + /> + )} + + + Tentukan waktu kapan bisa memulai pemilihan. + + + + )} + /> + + ( + + Waktu Selesai + + {settingsQuery.isLoading ? ( + + ) : ( + + e.target.value === "" + ? field.onChange(undefined) + : field.onChange(new Date(e.target.value)) + } + disabled={changeDuration.isPending} + /> + )} + + + Tentukan kapan batas waktu maksimal peserta dapat melakukan + pemilihan. + + + + )} + /> + + + + + ); } diff --git a/packages/api/src/router/settings.ts b/packages/api/src/router/settings.ts index c2878b29..4a1d4adf 100644 --- a/packages/api/src/router/settings.ts +++ b/packages/api/src/router/settings.ts @@ -29,13 +29,12 @@ export const settingsRouter = { return { success: true }; }), - // changeVotingTime: adminProcedure - // // .input(ServerPengaturanWaktuValidationSchema) - // .input() - // .mutation(({ input }) => { - // settings.updateSettings.startTime(input.startTime); - // settings.updateSettings.endTime(input.endTime); - // - // return { success: true } - // }), + changeVotingTime: adminProcedure + .input(settingsSchema.SharedDuration) + .mutation(({ input }) => { + settings.updateSettings.startTime(input.startTime); + settings.updateSettings.endTime(input.endTime); + + return { success: true }; + }), } satisfies TRPCRouterRecord; diff --git a/packages/validators/src/settings.ts b/packages/validators/src/settings.ts index 0ef6bb30..446d3262 100644 --- a/packages/validators/src/settings.ts +++ b/packages/validators/src/settings.ts @@ -11,7 +11,39 @@ const SharedBehaviour = z.object({ canAttend: z.boolean(), }); +const startTimeError = "Diperlukan kapan waktu mulai pemilihan!"; +const endTimeError = "Diperlukan kapan waktu selesai pemilihan!"; + +const SharedDuration = z + .object({ + startTime: z.date({ + errorMap: (issue, { defaultError }) => ({ + message: + issue.code === "invalid_type" + ? startTimeError + : issue.code === "required_error" + ? startTimeError + : defaultError, + }), + }), + endTime: z.date({ + errorMap: (issue, { defaultError }) => ({ + message: + issue.code === "invalid_type" + ? endTimeError + : issue.code === "required_error" + ? endTimeError + : defaultError, + }), + }), + }) + .refine((data) => data.startTime < data.endTime, { + path: ["endTime"], + message: "Waktu selesai tidak boleh kurang dari waktu mulai!", + }); + export const settings = { SharedCanLogin, SharedBehaviour, + SharedDuration, } as const; From af10c01140187e06298355f9ee9063cb7a74c8a5 Mon Sep 17 00:00:00 2001 From: Ezra Khairan Permana Date: Tue, 11 Jun 2024 11:33:02 +0700 Subject: [PATCH 24/44] feat: persiapan halaman partisipan --- apps/web/src/app/(auth)/admin/partisipan/page.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/(auth)/admin/partisipan/page.tsx b/apps/web/src/app/(auth)/admin/partisipan/page.tsx index 1ae95d29..72d54eeb 100644 --- a/apps/web/src/app/(auth)/admin/partisipan/page.tsx +++ b/apps/web/src/app/(auth)/admin/partisipan/page.tsx @@ -1,3 +1,14 @@ export default function ParticipantPage() { - return <>; + return ( +
+
+
+

Partisipan

+

+ Kelola partisipan yang tercantum sebagai daftar pemilih tetap. +

+
+
+
+ ); } From 652a783c2e4a6dc08ce5ca9418bf740a687f6746 Mon Sep 17 00:00:00 2001 From: Ezra Khairan Permana Date: Tue, 11 Jun 2024 13:16:38 +0700 Subject: [PATCH 25/44] feat: fitur qr dadakan --- apps/web/package.json | 2 + .../src/app/(auth)/admin/partisipan/page.tsx | 6 +- .../_components/participant/csv-upload.tsx | 0 .../_components/participant/data-table.tsx | 33 ++++ .../app/_components/participant/export.tsx | 0 .../participant/new-participant.tsx | 0 .../app/_components/participant/sudden-qr.tsx | 124 +++++++++++++ yarn.lock | 169 +++++++++++++++++- 8 files changed, 331 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/app/_components/participant/csv-upload.tsx create mode 100644 apps/web/src/app/_components/participant/data-table.tsx create mode 100644 apps/web/src/app/_components/participant/export.tsx create mode 100644 apps/web/src/app/_components/participant/new-participant.tsx create mode 100644 apps/web/src/app/_components/participant/sudden-qr.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 9416948c..5b03c137 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -29,6 +29,7 @@ "geist": "^1.3.0", "lucide-react": "^0.390.0", "next": "^14.2.3", + "qrcode": "^1.5.3", "react": "18.3.1", "react-dom": "18.3.1", "superjson": "2.2.1", @@ -40,6 +41,7 @@ "@sora-vp/tailwind-config": "*", "@sora-vp/tsconfig": "*", "@types/node": "^20.12.9", + "@types/qrcode": "^1", "@types/react": "^18.3.1", "@types/react-dom": "^18.3.0", "dotenv-cli": "^7.4.1", diff --git a/apps/web/src/app/(auth)/admin/partisipan/page.tsx b/apps/web/src/app/(auth)/admin/partisipan/page.tsx index 72d54eeb..fb538320 100644 --- a/apps/web/src/app/(auth)/admin/partisipan/page.tsx +++ b/apps/web/src/app/(auth)/admin/partisipan/page.tsx @@ -1,13 +1,17 @@ +import { DataTable } from "~/app/_components/participant/data-table"; + export default function ParticipantPage() { return (
-
+

Partisipan

Kelola partisipan yang tercantum sebagai daftar pemilih tetap.

+ +
); diff --git a/apps/web/src/app/_components/participant/csv-upload.tsx b/apps/web/src/app/_components/participant/csv-upload.tsx new file mode 100644 index 00000000..e69de29b diff --git a/apps/web/src/app/_components/participant/data-table.tsx b/apps/web/src/app/_components/participant/data-table.tsx new file mode 100644 index 00000000..ba6981de --- /dev/null +++ b/apps/web/src/app/_components/participant/data-table.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { FileJson, FileSpreadsheet, FileText, UserPlus } from "lucide-react"; + +import { Button } from "@sora-vp/ui/button"; + +import { SuddenQr } from "./sudden-qr"; + +export function DataTable() { + return ( +
+
+ + + + + +
+
+ ); +} diff --git a/apps/web/src/app/_components/participant/export.tsx b/apps/web/src/app/_components/participant/export.tsx new file mode 100644 index 00000000..e69de29b diff --git a/apps/web/src/app/_components/participant/new-participant.tsx b/apps/web/src/app/_components/participant/new-participant.tsx new file mode 100644 index 00000000..e69de29b diff --git a/apps/web/src/app/_components/participant/sudden-qr.tsx b/apps/web/src/app/_components/participant/sudden-qr.tsx new file mode 100644 index 00000000..aba450bd --- /dev/null +++ b/apps/web/src/app/_components/participant/sudden-qr.tsx @@ -0,0 +1,124 @@ +"use client;"; + +import { useEffect, useRef, useState } from "react"; +import { QrCode as QRIcon } from "lucide-react"; +import QRCode from "qrcode"; + +import { Button } from "@sora-vp/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@sora-vp/ui/dialog"; +import { FormControl, FormItem, FormLabel } from "@sora-vp/ui/form"; +import { Input } from "@sora-vp/ui/input"; + +type Timer = ReturnType; +type SomeFunction = (...args: any[]) => void; +/** + * + * @param func The original, non debounced function (You can pass any number of args to it) + * @param delay The delay (in ms) for the function to return + * @returns The debounced function, which will run only if the debounced function has not been called in the last (delay) ms + */ + +export function useDebounce( + func: Func, + delay = 1000, +) { + const timer = useRef(); + + useEffect(() => { + return () => { + if (!timer.current) return; + clearTimeout(timer.current); + }; + }, []); + + const debouncedFunction = ((...args) => { + const newTimer = setTimeout(() => { + func(...args); + }, delay); + clearTimeout(timer.current); + timer.current = newTimer; + }) as Func; + + return debouncedFunction; +} + +export function SuddenQr() { + const canvasRef = useRef(null!); + + const [isOpen, setDialogOpen] = useState(false); + const [qrInput, setQrInput] = useState(""); + + const debouncedFn = useDebounce((qr: string) => { + if (qr === "") { + const ctx = canvasRef.current.getContext("2d"); + ctx?.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height); + + return; + } + + QRCode.toCanvas(canvasRef.current, qr, { width: 296 }); + }); + + return ( + { + setDialogOpen((value) => { + const newValue = !value; + + if (!newValue) setQrInput(""); + + return newValue; + }); + }} + > + + + + + + + QR Dadakan + + + Masukan teks yang akan dijadikan kode QR. + + +
+ { + const val = e.target.value.trim(); + + setQrInput(val); + debouncedFn(val); + }} + /> + + +
+
+ + + + + + +
+
+ ); +} diff --git a/yarn.lock b/yarn.lock index e7db5fee..e8b939f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2881,6 +2881,7 @@ __metadata: "@trpc/react-query": "npm:11.0.0-rc.364" "@trpc/server": "npm:11.0.0-rc.364" "@types/node": "npm:^20.12.9" + "@types/qrcode": "npm:^1" "@types/react": "npm:^18.3.1" "@types/react-dom": "npm:^18.3.0" date-fns: "npm:^3.6.0" @@ -2891,6 +2892,7 @@ __metadata: lucide-react: "npm:^0.390.0" next: "npm:^14.2.3" prettier: "npm:^3.2.5" + qrcode: "npm:^1.5.3" react: "npm:18.3.1" react-dom: "npm:18.3.1" superjson: "npm:2.2.1" @@ -3203,6 +3205,15 @@ __metadata: languageName: node linkType: hard +"@types/qrcode@npm:^1": + version: 1.5.5 + resolution: "@types/qrcode@npm:1.5.5" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/b8e6709905d1edb32dda414408acab18ac4aefcbe7bf96d9e32ba94218f45b99c8938ba7a09863ce82a67b226195099fd0f48881d16ee844899087b7f249955f + languageName: node + linkType: hard + "@types/react-dom@npm:^18.3.0": version: 18.3.0 resolution: "@types/react-dom@npm:18.3.0" @@ -4301,6 +4312,13 @@ __metadata: languageName: node linkType: hard +"camelcase@npm:^5.0.0": + version: 5.3.1 + resolution: "camelcase@npm:5.3.1" + checksum: 10c0/92ff9b443bfe8abb15f2b1513ca182d16126359ad4f955ebc83dc4ddcc4ef3fdd2c078bc223f2673dc223488e75c99b16cc4d056624374b799e6a1555cf61b23 + languageName: node + linkType: hard + "camelcase@npm:^7.0.1": version: 7.0.1 resolution: "camelcase@npm:7.0.1" @@ -4491,6 +4509,17 @@ __metadata: languageName: node linkType: hard +"cliui@npm:^6.0.0": + version: 6.0.0 + resolution: "cliui@npm:6.0.0" + dependencies: + string-width: "npm:^4.2.0" + strip-ansi: "npm:^6.0.0" + wrap-ansi: "npm:^6.2.0" + checksum: 10c0/35229b1bb48647e882104cac374c9a18e34bbf0bace0e2cf03000326b6ca3050d6b59545d91e17bfe3705f4a0e2988787aa5cde6331bf5cbbf0164732cef6492 + languageName: node + linkType: hard + "cliui@npm:^8.0.1": version: 8.0.1 resolution: "cliui@npm:8.0.1" @@ -4898,6 +4927,13 @@ __metadata: languageName: node linkType: hard +"decamelize@npm:^1.2.0": + version: 1.2.0 + resolution: "decamelize@npm:1.2.0" + checksum: 10c0/85c39fe8fbf0482d4a1e224ef0119db5c1897f8503bcef8b826adff7a1b11414972f6fef2d7dec2ee0b4be3863cf64ac1439137ae9e6af23a3d8dcbe26a5b4b2 + languageName: node + linkType: hard + "deep-extend@npm:^0.6.0": version: 0.6.0 resolution: "deep-extend@npm:0.6.0" @@ -5079,6 +5115,13 @@ __metadata: languageName: node linkType: hard +"dijkstrajs@npm:^1.0.1": + version: 1.0.3 + resolution: "dijkstrajs@npm:1.0.3" + checksum: 10c0/2183d61ac1f25062f3c3773f3ea8d9f45ba164a00e77e07faf8cc5750da966222d1e2ce6299c875a80f969190c71a0973042192c5624d5223e4ed196ff584c99 + languageName: node + linkType: hard + "dir-glob@npm:^3.0.1": version: 3.0.1 resolution: "dir-glob@npm:3.0.1" @@ -5319,6 +5362,13 @@ __metadata: languageName: node linkType: hard +"encode-utf8@npm:^1.0.3": + version: 1.0.3 + resolution: "encode-utf8@npm:1.0.3" + checksum: 10c0/6b3458b73e868113d31099d7508514a5c627d8e16d1e0542d1b4e3652299b8f1f590c468e2b9dcdf1b4021ee961f31839d0be9d70a7f2a8a043c63b63c9b3a88 + languageName: node + linkType: hard + "encodeurl@npm:~1.0.2": version: 1.0.2 resolution: "encodeurl@npm:1.0.2" @@ -6412,6 +6462,16 @@ __metadata: languageName: node linkType: hard +"find-up@npm:^4.1.0": + version: 4.1.0 + resolution: "find-up@npm:4.1.0" + dependencies: + locate-path: "npm:^5.0.0" + path-exists: "npm:^4.0.0" + checksum: 10c0/0406ee89ebeefa2d507feb07ec366bebd8a6167ae74aa4e34fb4c4abd06cf782a3ce26ae4194d70706f72182841733f00551c209fe575cb00bd92104056e78c1 + languageName: node + linkType: hard + "find-up@npm:^5.0.0": version: 5.0.0 resolution: "find-up@npm:5.0.0" @@ -6599,7 +6659,7 @@ __metadata: languageName: node linkType: hard -"get-caller-file@npm:^2.0.5": +"get-caller-file@npm:^2.0.1, get-caller-file@npm:^2.0.5": version: 2.0.5 resolution: "get-caller-file@npm:2.0.5" checksum: 10c0/c6c7b60271931fa752aeb92f2b47e355eac1af3a2673f47c9589e8f8a41adc74d45551c1bc57b5e66a80609f10ffb72b6f575e4370d61cc3f7f3aaff01757cde @@ -8057,6 +8117,15 @@ __metadata: languageName: node linkType: hard +"locate-path@npm:^5.0.0": + version: 5.0.0 + resolution: "locate-path@npm:5.0.0" + dependencies: + p-locate: "npm:^4.1.0" + checksum: 10c0/33a1c5247e87e022f9713e6213a744557a3e9ec32c5d0b5efb10aa3a38177615bf90221a5592674857039c1a0fd2063b82f285702d37b792d973e9e72ace6c59 + languageName: node + linkType: hard + "locate-path@npm:^6.0.0": version: 6.0.0 resolution: "locate-path@npm:6.0.0" @@ -9259,6 +9328,15 @@ __metadata: languageName: node linkType: hard +"p-limit@npm:^2.2.0": + version: 2.3.0 + resolution: "p-limit@npm:2.3.0" + dependencies: + p-try: "npm:^2.0.0" + checksum: 10c0/8da01ac53efe6a627080fafc127c873da40c18d87b3f5d5492d465bb85ec7207e153948df6b9cbaeb130be70152f874229b8242ee2be84c0794082510af97f12 + languageName: node + linkType: hard + "p-limit@npm:^3.0.2": version: 3.1.0 resolution: "p-limit@npm:3.1.0" @@ -9268,6 +9346,15 @@ __metadata: languageName: node linkType: hard +"p-locate@npm:^4.1.0": + version: 4.1.0 + resolution: "p-locate@npm:4.1.0" + dependencies: + p-limit: "npm:^2.2.0" + checksum: 10c0/1b476ad69ad7f6059744f343b26d51ce091508935c1dbb80c4e0a2f397ffce0ca3a1f9f5cd3c7ce19d7929a09719d5c65fe70d8ee289c3f267cd36f2881813e9 + languageName: node + linkType: hard + "p-locate@npm:^5.0.0": version: 5.0.0 resolution: "p-locate@npm:5.0.0" @@ -9295,6 +9382,13 @@ __metadata: languageName: node linkType: hard +"p-try@npm:^2.0.0": + version: 2.2.0 + resolution: "p-try@npm:2.2.0" + checksum: 10c0/c36c19907734c904b16994e6535b02c36c2224d433e01a2f1ab777237f4d86e6289fd5fd464850491e940379d4606ed850c03e0f9ab600b0ebddb511312e177f + languageName: node + linkType: hard + "pac-proxy-agent@npm:^7.0.1": version: 7.0.1 resolution: "pac-proxy-agent@npm:7.0.1" @@ -9539,6 +9633,13 @@ __metadata: languageName: node linkType: hard +"pngjs@npm:^5.0.0": + version: 5.0.0 + resolution: "pngjs@npm:5.0.0" + checksum: 10c0/c074d8a94fb75e2defa8021e85356bf7849688af7d8ce9995b7394d57cd1a777b272cfb7c4bce08b8d10e71e708e7717c81fd553a413f21840c548ec9d4893c6 + languageName: node + linkType: hard + "possible-typed-array-names@npm:^1.0.0": version: 1.0.0 resolution: "possible-typed-array-names@npm:1.0.0" @@ -9837,6 +9938,20 @@ __metadata: languageName: node linkType: hard +"qrcode@npm:^1.5.3": + version: 1.5.3 + resolution: "qrcode@npm:1.5.3" + dependencies: + dijkstrajs: "npm:^1.0.1" + encode-utf8: "npm:^1.0.3" + pngjs: "npm:^5.0.0" + yargs: "npm:^15.3.1" + bin: + qrcode: bin/qrcode + checksum: 10c0/eb961cd8246e00ae338b6d4a3a28574174456db42cec7070aa2b315fb6576b7f040b0e4347be290032e447359a145c68cb60ef884d55ca3e1076294fed46f719 + languageName: node + linkType: hard + "querystringify@npm:^2.1.1": version: 2.2.0 resolution: "querystringify@npm:2.2.0" @@ -10172,6 +10287,13 @@ __metadata: languageName: node linkType: hard +"require-main-filename@npm:^2.0.0": + version: 2.0.0 + resolution: "require-main-filename@npm:2.0.0" + checksum: 10c0/db91467d9ead311b4111cbd73a4e67fa7820daed2989a32f7023785a2659008c6d119752d9c4ac011ae07e537eb86523adff99804c5fdb39cd3a017f9b401bb6 + languageName: node + linkType: hard + "requires-port@npm:^1.0.0": version: 1.0.0 resolution: "requires-port@npm:1.0.0" @@ -12144,6 +12266,13 @@ __metadata: languageName: node linkType: hard +"which-module@npm:^2.0.0": + version: 2.0.1 + resolution: "which-module@npm:2.0.1" + checksum: 10c0/087038e7992649eaffa6c7a4f3158d5b53b14cf5b6c1f0e043dccfacb1ba179d12f17545d5b85ebd94a42ce280a6fe65d0cbcab70f4fc6daad1dfae85e0e6a3e + languageName: node + linkType: hard + "which-typed-array@npm:^1.1.14, which-typed-array@npm:^1.1.15, which-typed-array@npm:^1.1.9": version: 1.1.15 resolution: "which-typed-array@npm:1.1.15" @@ -12213,7 +12342,7 @@ __metadata: languageName: node linkType: hard -"wrap-ansi@npm:^6.0.1": +"wrap-ansi@npm:^6.0.1, wrap-ansi@npm:^6.2.0": version: 6.2.0 resolution: "wrap-ansi@npm:6.2.0" dependencies: @@ -12242,6 +12371,13 @@ __metadata: languageName: node linkType: hard +"y18n@npm:^4.0.0": + version: 4.0.3 + resolution: "y18n@npm:4.0.3" + checksum: 10c0/308a2efd7cc296ab2c0f3b9284fd4827be01cfeb647b3ba18230e3a416eb1bc887ac050de9f8c4fd9e7856b2e8246e05d190b53c96c5ad8d8cb56dffb6f81024 + languageName: node + linkType: hard + "y18n@npm:^5.0.5": version: 5.0.8 resolution: "y18n@npm:5.0.8" @@ -12272,6 +12408,16 @@ __metadata: languageName: node linkType: hard +"yargs-parser@npm:^18.1.2": + version: 18.1.3 + resolution: "yargs-parser@npm:18.1.3" + dependencies: + camelcase: "npm:^5.0.0" + decamelize: "npm:^1.2.0" + checksum: 10c0/25df918833592a83f52e7e4f91ba7d7bfaa2b891ebf7fe901923c2ee797534f23a176913ff6ff7ebbc1cc1725a044cc6a6539fed8bfd4e13b5b16376875f9499 + languageName: node + linkType: hard + "yargs-parser@npm:^21.1.1": version: 21.1.1 resolution: "yargs-parser@npm:21.1.1" @@ -12279,6 +12425,25 @@ __metadata: languageName: node linkType: hard +"yargs@npm:^15.3.1": + version: 15.4.1 + resolution: "yargs@npm:15.4.1" + dependencies: + cliui: "npm:^6.0.0" + decamelize: "npm:^1.2.0" + find-up: "npm:^4.1.0" + get-caller-file: "npm:^2.0.1" + require-directory: "npm:^2.1.1" + require-main-filename: "npm:^2.0.0" + set-blocking: "npm:^2.0.0" + string-width: "npm:^4.2.0" + which-module: "npm:^2.0.0" + y18n: "npm:^4.0.0" + yargs-parser: "npm:^18.1.2" + checksum: 10c0/f1ca680c974333a5822732825cca7e95306c5a1e7750eb7b973ce6dc4f97a6b0a8837203c8b194f461969bfe1fb1176d1d423036635285f6010b392fa498ab2d + languageName: node + linkType: hard + "yargs@npm:^17.5.1": version: 17.7.2 resolution: "yargs@npm:17.7.2" From 15a7fb3a2bffa59e312798b62bd68aa53e3f9173 Mon Sep 17 00:00:00 2001 From: Ezra Khairan Permana Date: Tue, 11 Jun 2024 14:08:22 +0700 Subject: [PATCH 26/44] feat: masih membuat tabel partisipan --- .../_components/participant/data-table.tsx | 284 +++++++++++++++++- .../app/_components/participant/sudden-qr.tsx | 2 +- packages/api/src/root.ts | 2 + packages/api/src/router/participant.ts | 12 + 4 files changed, 292 insertions(+), 8 deletions(-) create mode 100644 packages/api/src/router/participant.ts diff --git a/apps/web/src/app/_components/participant/data-table.tsx b/apps/web/src/app/_components/participant/data-table.tsx index ba6981de..6730dac4 100644 --- a/apps/web/src/app/_components/participant/data-table.tsx +++ b/apps/web/src/app/_components/participant/data-table.tsx @@ -1,33 +1,303 @@ "use client"; -import { FileJson, FileSpreadsheet, FileText, UserPlus } from "lucide-react"; +import type { + ColumnDef, + ColumnFiltersState, + SortingState, + VisibilityState, +} from "@tanstack/react-table"; +import { useState } from "react"; +import { + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { + ChevronDown, + ChevronLeftIcon, + ChevronRightIcon, + ChevronsLeft, + ChevronsRight, + FileJson, + FileSpreadsheet, + FileText, + UserPlus, +} from "lucide-react"; import { Button } from "@sora-vp/ui/button"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@sora-vp/ui/dropdown-menu"; +import { Input } from "@sora-vp/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@sora-vp/ui/select"; +import { Skeleton } from "@sora-vp/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@sora-vp/ui/table"; +import { api } from "~/trpc/react"; import { SuddenQr } from "./sudden-qr"; +const columns: ColumnDef[] = []; + export function DataTable() { + const participantQuery = api.participant.getAllParticipants.useQuery(); + + const [sorting, setSorting] = useState([]); + const [columnFilters, setColumnFilters] = useState([]); + const [columnVisibility, setColumnVisibility] = useState({}); + const [rowSelection, setRowSelection] = useState({}); + + const table = useReactTable({ + data: participantQuery.data ?? [], + columns, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + initialState: { pagination: { pageSize: 20 } }, + state: { + sorting, + columnFilters, + columnVisibility, + rowSelection, + }, + }); + return ( -
-
+
+
+ +
+
+ + table.getColumn("name")?.setFilterValue(event.target.value) + } + className="max-w-2xl" + /> + + + + + + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + > + {column.id} + + ); + })} + + +
+
+
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} + + ))} + + + {participantQuery.isError ? ( + + + Error: {participantQuery.error.message} + + + ) : null} + + {participantQuery.isLoading && !participantQuery.isError ? ( + <> + {Array.from({ length: 10 }).map((_, idx) => ( + + + + + + ))} + + ) : null} + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + <> + {!participantQuery.isLoading && ( + <> + {!participantQuery.isError && ( + + + Tidak ada data. + + + )} + + )} + + )} + +
+
+
+
+
+

Baris per halaman

+ +
+
+ Halaman {table.getState().pagination.pageIndex + 1} dari{" "} + {table.getPageCount()} +
+
+ + + + +
+
+
+
); } diff --git a/apps/web/src/app/_components/participant/sudden-qr.tsx b/apps/web/src/app/_components/participant/sudden-qr.tsx index aba450bd..c1044dfc 100644 --- a/apps/web/src/app/_components/participant/sudden-qr.tsx +++ b/apps/web/src/app/_components/participant/sudden-qr.tsx @@ -84,7 +84,7 @@ export function SuddenQr() { diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index ed40c5d0..fa528cd3 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -1,5 +1,6 @@ import { adminRouter } from "./router/admin"; import { authRouter } from "./router/auth"; +import { participantRouter } from "./router/participant"; import { settingsRouter } from "./router/settings"; import { createTRPCRouter } from "./trpc"; @@ -7,6 +8,7 @@ export const appRouter = createTRPCRouter({ auth: authRouter, settings: settingsRouter, admin: adminRouter, + participant: participantRouter, }); // export type definition of API diff --git a/packages/api/src/router/participant.ts b/packages/api/src/router/participant.ts new file mode 100644 index 00000000..6fae8545 --- /dev/null +++ b/packages/api/src/router/participant.ts @@ -0,0 +1,12 @@ +import type { TRPCRouterRecord } from "@trpc/server"; + +// import { and, eq, not, schema, sql } from "@sora-vp/db"; +// import { admin } from "@sora-vp/validators"; + +import { protectedProcedure } from "../trpc"; + +export const participantRouter = { + getAllParticipants: protectedProcedure.query(async () => { + return []; + }), +} satisfies TRPCRouterRecord; From 5f67806bbc65d2433c2f82f6e46f9d14ded17e9c Mon Sep 17 00:00:00 2001 From: Ezra Khairan Permana Date: Wed, 12 Jun 2024 14:27:09 +0700 Subject: [PATCH 27/44] feat: menambahkan tabel, memperbaiki style, dll --- .../src/app/(auth)/admin/partisipan/page.tsx | 18 +- apps/web/src/app/(auth)/layout.tsx | 4 +- .../_components/admin/pending-user/index.tsx | 2 +- .../app/_components/nav/resizeable-nav.tsx | 39 +- .../_components/participant/data-table.tsx | 614 ++++++++++++------ .../participant/new-participant.tsx | 171 +++++ .../app/_components/participant/sudden-qr.tsx | 2 +- packages/api/src/router/participant.ts | 16 +- packages/api/src/router/settings.ts | 4 +- packages/db/package.json | 1 + packages/db/src/index.ts | 4 + packages/db/src/schema/main.ts | 6 +- packages/validators/package.json | 1 + packages/validators/src/index.ts | 1 + packages/validators/src/participant.ts | 57 ++ yarn.lock | 2 + 16 files changed, 695 insertions(+), 247 deletions(-) create mode 100644 packages/validators/src/participant.ts diff --git a/apps/web/src/app/(auth)/admin/partisipan/page.tsx b/apps/web/src/app/(auth)/admin/partisipan/page.tsx index fb538320..25376919 100644 --- a/apps/web/src/app/(auth)/admin/partisipan/page.tsx +++ b/apps/web/src/app/(auth)/admin/partisipan/page.tsx @@ -2,17 +2,15 @@ import { DataTable } from "~/app/_components/participant/data-table"; export default function ParticipantPage() { return ( -
-
-
-

Partisipan

-

- Kelola partisipan yang tercantum sebagai daftar pemilih tetap. -

-
- - +
+
+

Partisipan

+

+ Kelola partisipan yang tercantum sebagai daftar pemilih tetap. +

+ +
); } diff --git a/apps/web/src/app/(auth)/layout.tsx b/apps/web/src/app/(auth)/layout.tsx index dcaf9242..314a06e2 100644 --- a/apps/web/src/app/(auth)/layout.tsx +++ b/apps/web/src/app/(auth)/layout.tsx @@ -78,9 +78,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) { nameFallback={isLoggedIn.user.name?.slice(0, 2) ?? ""} role={isLoggedIn.user.role} > -
- {props.children} -
+ {props.children} diff --git a/apps/web/src/app/_components/admin/pending-user/index.tsx b/apps/web/src/app/_components/admin/pending-user/index.tsx index 8f4ae924..615982c9 100644 --- a/apps/web/src/app/_components/admin/pending-user/index.tsx +++ b/apps/web/src/app/_components/admin/pending-user/index.tsx @@ -174,7 +174,7 @@ export function PendingUser() { }); return ( -
+
diff --git a/apps/web/src/app/_components/nav/resizeable-nav.tsx b/apps/web/src/app/_components/nav/resizeable-nav.tsx index 1477169d..53fb45c7 100644 --- a/apps/web/src/app/_components/nav/resizeable-nav.tsx +++ b/apps/web/src/app/_components/nav/resizeable-nav.tsx @@ -71,7 +71,7 @@ export function ResizeableNav({ className="min-h-screen w-screen" > - - - -
- {isCollapsed ? ( -
- ᮞᮧᮛ -
- ) : null} + +
+
+ {isCollapsed ? ( +
+ ᮞᮧᮛ +
+ ) : null} - -
- - - {children} - - + +
+
+ +
{children}
); diff --git a/apps/web/src/app/_components/participant/data-table.tsx b/apps/web/src/app/_components/participant/data-table.tsx index 6730dac4..34c8c722 100644 --- a/apps/web/src/app/_components/participant/data-table.tsx +++ b/apps/web/src/app/_components/participant/data-table.tsx @@ -6,7 +6,8 @@ import type { SortingState, VisibilityState, } from "@tanstack/react-table"; -import { useState } from "react"; +import { createContext, useContext, useEffect, useState } from "react"; +import { Space_Mono } from "next/font/google"; import { flexRender, getCoreRowModel, @@ -15,7 +16,10 @@ import { getSortedRowModel, useReactTable, } from "@tanstack/react-table"; +import { format, formatDuration, intervalToDuration } from "date-fns"; +import { id } from "date-fns/locale"; import { + ArrowUpDown, ChevronDown, ChevronLeftIcon, ChevronRightIcon, @@ -24,7 +28,9 @@ import { FileJson, FileSpreadsheet, FileText, - UserPlus, + MoreHorizontal, + PencilLine, + Trash2, } from "lucide-react"; import { Button } from "@sora-vp/ui/button"; @@ -54,20 +60,217 @@ import { TableHeader, TableRow, } from "@sora-vp/ui/table"; +import { toast } from "@sora-vp/ui/toast"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@sora-vp/ui/tooltip"; import { api } from "~/trpc/react"; +import { NewParticipant } from "./new-participant"; import { SuddenQr } from "./sudden-qr"; -const columns: ColumnDef[] = []; +const GlobalSystemAllowance = createContext(true); + +const MonoFont = Space_Mono({ + weight: "400", + subsets: ["latin"], +}); + +const columns: ColumnDef[] = [ + { + id: "participantName", + accessorKey: "name", + header: "Nama Peserta", + }, + { + accessorKey: "subpart", + header: ({ column }) => { + return ( + + ); + }, + }, + { + accessorKey: "qrId", + header: "QR ID", + cell: ({ row }) => ( + + { + navigator.clipboard.writeText(row.getValue("qrId")); + toast.success("Berhasil menyalin QR ID!"); + }} + > +
{row.getValue("qrId")}
+
+ +

Klik untuk menyalin

+
+
+ ), + }, + { + accessorKey: "alreadyAttended", + header: "Sudah Absen?", + cell: ({ row }) => ( +
+ + + + {row.getValue("alreadyAttended") ? "✅" : "❌"} + + + +

+ {row.getValue("alreadyAttended") + ? format( + row.original.attendedAt, + "EEEE, dd MMMM yyy, kk.mm.ss", + { + locale: id, + }, + ) + : "Belum absen"} +

+
+
+
+ ), + }, + { + accessorKey: "alreadyChoosing", + header: "Sudah Memilih?", + cell: ({ row }) => ( +
+ + + + {row.getValue("alreadyChoosing") ? "✅" : "❌"} + + + +

+ {row.getValue("alreadyChoosing") + ? format( + row.original.choosingAt, + "EEEE, dd MMMM yyy, kk.mm.ss", + { + locale: id, + }, + ) + : "Belum memilih"} +

+
+
+
+ ), + }, + { + accessorKey: "duration", + header: "Durasi Memilih", + cell: ({ row }) => ( + <> + {row.getValue("alreadyAttended") && row.getValue("alreadyChoosing") ? ( + + {formatDuration( + intervalToDuration({ + start: row.original.attendedAt, + end: row.original.choosingAt, + }), + { locale: id }, + )} + + ) : ( +
+ {"❌"} +
+ )} + + ), + }, + { + id: "actions", + enableHiding: false, + cell: ({ row }) => { + const participant = row.original; + + // eslint-disable-next-line react-hooks/rules-of-hooks + const [openDelete, setOpenDelete] = useState(false); + + // eslint-disable-next-line react-hooks/rules-of-hooks + const globallyAllowedToOpen = useContext(GlobalSystemAllowance); + + // // eslint-disable-next-line react-hooks/rules-of-hooks + // const closeDialog = useCallback(() => setOpenDelete((prev) => !prev), []); + + return ( + <> + + + + + + Aksi + + + Ubah Identitas + + setOpenDelete(true)} + disabled={participant.alreadyAttended} + > + + Hapus Peserta + + + + + ); + }, + }, +]; export function DataTable() { - const participantQuery = api.participant.getAllParticipants.useQuery(); + const participantQuery = api.participant.getAllParticipants.useQuery( + undefined, + { + refetchInterval: 2500, + refetchOnWindowFocus: true, + }, + ); + + const settingsQuery = api.settings.getSettings.useQuery(undefined, { + refetchInterval: 5500, + refetchIntervalInBackground: true, + }); const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); const [columnVisibility, setColumnVisibility] = useState({}); const [rowSelection, setRowSelection] = useState({}); + const [allowedToOpenModifier, setAllowedOpen] = useState(true); + const table = useReactTable({ data: participantQuery.data ?? [], columns, @@ -88,216 +291,229 @@ export function DataTable() { }, }); + useEffect(() => { + if (settingsQuery.data) { + setAllowedOpen(!settingsQuery.data.canAttend); + } + }, [settingsQuery.data]); + return ( -
-
- - - - - -
+ +
+
+ + + + + +
-
-
- - table.getColumn("name")?.setFilterValue(event.target.value) - } - className="max-w-2xl" - /> +
+
+ + table + .getColumn("participantName") + ?.setFilterValue(event.target.value) + } + className="max-w-2xl" + /> - - - - - - {table - .getAllColumns() - .filter((column) => column.getCanHide()) - .map((column) => { - return ( - - column.toggleVisibility(!!value) - } - > - {column.id} - - ); - })} - - -
-
-
- - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { + + + + + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { return ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext(), - )} - + + column.toggleVisibility(!!value) + } + > + {column.id} + ); })} - - ))} - - - {participantQuery.isError ? ( - - - Error: {participantQuery.error.message} - - - ) : null} - - {participantQuery.isLoading && !participantQuery.isError ? ( - <> - {Array.from({ length: 10 }).map((_, idx) => ( - - - - - - ))} - - ) : null} + + + +
+
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} + + ))} + + + {participantQuery.isError ? ( + + + Error: {participantQuery.error.message} + + + ) : null} - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} - + {participantQuery.isLoading && !participantQuery.isError ? ( + <> + {Array.from({ length: 10 }).map((_, idx) => ( + + + + + ))} - - )) - ) : ( - <> - {!participantQuery.isLoading && ( - <> - {!participantQuery.isError && ( - - - Tidak ada data. + + ) : null} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} - + ))} + + )) + ) : ( + <> + {!participantQuery.isLoading && ( + <> + {!participantQuery.isError && ( + + + Tidak ada data. + + + )} + )} )} - - )} - -
-
-
-
-
-

Baris per halaman

- -
-
- Halaman {table.getState().pagination.pageIndex + 1} dari{" "} - {table.getPageCount()} -
-
- - - - + + +
+
+
+
+
+

Baris per halaman

+ +
+
+ Halaman {table.getState().pagination.pageIndex + 1} dari{" "} + {table.getPageCount()} +
+
+ + + + +
-
+ ); } diff --git a/apps/web/src/app/_components/participant/new-participant.tsx b/apps/web/src/app/_components/participant/new-participant.tsx index e69de29b..ee326fea 100644 --- a/apps/web/src/app/_components/participant/new-participant.tsx +++ b/apps/web/src/app/_components/participant/new-participant.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { UserPlus } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { Button } from "@sora-vp/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@sora-vp/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@sora-vp/ui/form"; +import { Input } from "@sora-vp/ui/input"; +import { toast } from "@sora-vp/ui/toast"; +import { participant } from "@sora-vp/validators"; + +import { api } from "~/trpc/react"; + +type FormSchema = z.infer; + +export function NewParticipant() { + const [isOpen, setDialogOpen] = useState(false); + + const apiUtils = api.useUtils(); + + const form = useForm({ + resolver: zodResolver(participant.SharedAddPariticipant), + defaultValues: { + name: "", + subpart: "", + }, + }); + + const participantMutation = api.participant.createNewParticipant.useMutation({ + onSuccess() { + toast.success("Operasi penambahan berhasil!", { + description: "Berhasil menambahkan pemilih tetap baru.", + }); + + form.reset(); + setDialogOpen(false); + }, + + onError(result) { + toast.error("Gagal menambahkan peserta, coba lagi nanti.", { + description: result.message, + }); + }, + + async onSettled() { + await apiUtils.participant.getAllParticipants.invalidate(); + }, + }); + + return ( + { + setDialogOpen((value) => { + const newValue = !value; + + if (!newValue) form.reset(); + + return newValue; + }); + }} + > + + + + + + + Tambah Partisipan Baru + + + Masukan informasi peserta pemilihan yang baru. + + +
+ + participantMutation.mutate(data), + )} + className="space-y-3" + > + ( + + Nama Peserta + + + + + Nama peserta yang akan masuk menjadi daftar pemilih tetap. + + + + )} + /> + ( + + Peserta Bagian Dari + + + + + Pengelompokan jenis peserta (siswa, guru, panitia, dll). + + + + )} + /> + +
+ + + + + +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/app/_components/participant/sudden-qr.tsx b/apps/web/src/app/_components/participant/sudden-qr.tsx index c1044dfc..77d6cf19 100644 --- a/apps/web/src/app/_components/participant/sudden-qr.tsx +++ b/apps/web/src/app/_components/participant/sudden-qr.tsx @@ -1,4 +1,4 @@ -"use client;"; +"use client"; import { useEffect, useRef, useState } from "react"; import { QrCode as QRIcon } from "lucide-react"; diff --git a/packages/api/src/router/participant.ts b/packages/api/src/router/participant.ts index 6fae8545..25db630f 100644 --- a/packages/api/src/router/participant.ts +++ b/packages/api/src/router/participant.ts @@ -1,12 +1,18 @@ import type { TRPCRouterRecord } from "@trpc/server"; -// import { and, eq, not, schema, sql } from "@sora-vp/db"; -// import { admin } from "@sora-vp/validators"; +import { preparedGetAllParticipants, schema } from "@sora-vp/db"; +import { participant } from "@sora-vp/validators"; import { protectedProcedure } from "../trpc"; export const participantRouter = { - getAllParticipants: protectedProcedure.query(async () => { - return []; - }), + getAllParticipants: protectedProcedure.query(() => + preparedGetAllParticipants.execute(), + ), + + createNewParticipant: protectedProcedure + .input(participant.SharedAddPariticipant) + .mutation(({ ctx, input }) => + ctx.db.transaction((tx) => tx.insert(schema.participants).values(input)), + ), } satisfies TRPCRouterRecord; diff --git a/packages/api/src/router/settings.ts b/packages/api/src/router/settings.ts index 4a1d4adf..181db782 100644 --- a/packages/api/src/router/settings.ts +++ b/packages/api/src/router/settings.ts @@ -3,10 +3,10 @@ import type { TRPCRouterRecord } from "@trpc/server"; import settings from "@sora-vp/settings"; import { settings as settingsSchema } from "@sora-vp/validators"; -import { adminProcedure, publicProcedure } from "../trpc"; +import { adminProcedure } from "../trpc"; export const settingsRouter = { - getSettings: publicProcedure.query(() => settings.getSettings()), + getSettings: adminProcedure.query(() => settings.getSettings()), getCanLoginStatus: adminProcedure.query(() => { const { canLogin } = settings.getSettings(); diff --git a/packages/db/package.json b/packages/db/package.json index b579c73a..35f5cc5e 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -22,6 +22,7 @@ "with-env": "dotenv -e ../../.env --" }, "dependencies": { + "@sora-vp/id-generator": "*", "@t3-oss/env-core": "^0.10.1", "drizzle-orm": "^0.30.10", "mysql2": "^3.9.7", diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 3e95cde4..03870cd6 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -28,3 +28,7 @@ export const countUserTable = db .select({ count: sql`count(*)`.mapWith(Number) }) .from(schema.users) .prepare(); + +export const preparedGetAllParticipants = db.query.participants + .findMany() + .prepare(); diff --git a/packages/db/src/schema/main.ts b/packages/db/src/schema/main.ts index 371ce07c..b1c95348 100644 --- a/packages/db/src/schema/main.ts +++ b/packages/db/src/schema/main.ts @@ -8,6 +8,8 @@ import { varchar, } from "drizzle-orm/mysql-core"; +import { nanoid } from "@sora-vp/id-generator"; + import { mySqlTable } from "./_table"; export const users = mySqlTable( @@ -38,7 +40,9 @@ export const participants = mySqlTable( id: int("id").autoincrement().primaryKey(), name: text("name").notNull(), subpart: varchar("sub_part", { length: 50 }).notNull(), - qrId: varchar("qr_id", { length: 30 }).notNull(), + qrId: varchar("qr_id", { length: 30 }) + .$defaultFn(() => nanoid()) + .notNull(), // CRITICAL FEATURE, for presence functionality alreadyAttended: boolean("already_attended").default(false).notNull(), diff --git a/packages/validators/package.json b/packages/validators/package.json index 41f44cd7..7f529b10 100644 --- a/packages/validators/package.json +++ b/packages/validators/package.json @@ -19,6 +19,7 @@ "typecheck": "tsc --noEmit --emitDeclarationOnly false" }, "dependencies": { + "@sora-vp/id-generator": "*", "zod": "^3.23.6" }, "devDependencies": { diff --git a/packages/validators/src/index.ts b/packages/validators/src/index.ts index 97a5e24b..e4c069ef 100644 --- a/packages/validators/src/index.ts +++ b/packages/validators/src/index.ts @@ -1,3 +1,4 @@ export * from "./auth"; export * from "./admin"; export * from "./settings"; +export * from "./participant"; diff --git a/packages/validators/src/participant.ts b/packages/validators/src/participant.ts new file mode 100644 index 00000000..8ad9d5e0 --- /dev/null +++ b/packages/validators/src/participant.ts @@ -0,0 +1,57 @@ +import { z } from "zod"; + +import { validateId } from "@sora-vp/id-generator"; + +const baseNameSchema = z + .string() + .min(1, { message: "Diperlukan nama peserta!" }) + .regex(/^[a-zA-Z0-9.,'\s`-]+$/, { + message: + "Hanya diperbolehkan menulis alfabet, angka, koma, petik satu, dan titik!", + }); +const baseSubpartSchema = z + .string() + .min(1, { message: "Diperlukan bagian darimana peserta ini!" }) + .regex(/^[a-zA-Z0-9-_]+$/, { + message: "Hanya diperbolehkan menulis alfabet, angka, dan garis bawah!", + }); + +const SharedAddPariticipant = z.object({ + name: baseNameSchema, + subpart: baseSubpartSchema, +}); + +const ServerUploadManyParticipant = z.array( + z.object({ Nama: baseNameSchema, "Bagian Dari": baseSubpartSchema }), +); + +const UploadParticipantSchema = z.object({ + csv: z + .any() + .refine((files) => files?.length == 1, "Diperlukan file csv!") + .refine( + (files) => files?.[0]?.type === "text/csv", + "Hanya format file csv yang diterima!", + ), +}); + +// export type TUploadFormValues = { csv: FileList }; + +const DeleteParticipantSchema = z.object({ id: z.number() }); + +const ParticipantAttendSchema = z.string().refine(validateId); + +const SharedUpdateParticipant = z.object({ + name: baseNameSchema, + subpart: baseSubpartSchema, + qrId: ParticipantAttendSchema, +}); + +export const participant = { + SharedAddPariticipant, + ServerUploadManyParticipant, + UploadParticipantSchema, + DeleteParticipantSchema, + ParticipantAttendSchema, + SharedUpdateParticipant, +} as const; diff --git a/yarn.lock b/yarn.lock index e8b939f1..fb29e114 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2678,6 +2678,7 @@ __metadata: resolution: "@sora-vp/db@workspace:packages/db" dependencies: "@sora-vp/eslint-config": "npm:*" + "@sora-vp/id-generator": "npm:*" "@sora-vp/prettier-config": "npm:*" "@sora-vp/tsconfig": "npm:*" "@t3-oss/env-core": "npm:^0.10.1" @@ -2852,6 +2853,7 @@ __metadata: resolution: "@sora-vp/validators@workspace:packages/validators" dependencies: "@sora-vp/eslint-config": "npm:*" + "@sora-vp/id-generator": "npm:*" "@sora-vp/prettier-config": "npm:*" "@sora-vp/tsconfig": "npm:*" eslint: "npm:^9.2.0" From f377e93e56361e48b4e8f98192892db024b402f3 Mon Sep 17 00:00:00 2001 From: Ezra Khairan Permana Date: Wed, 12 Jun 2024 17:11:48 +0700 Subject: [PATCH 28/44] feat: menambahkan fungsionalitas tambah peserta banyak dengan csv --- apps/web/package.json | 1 + .../_components/participant/data-table.tsx | 12 +- .../participant/new-participant.tsx | 336 +++++++++++++----- packages/api/src/router/participant.ts | 45 ++- packages/validators/src/participant.ts | 6 +- yarn.lock | 8 + 6 files changed, 297 insertions(+), 111 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 5b03c137..38ffdb4f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -25,6 +25,7 @@ "@trpc/client": "11.0.0-rc.364", "@trpc/react-query": "11.0.0-rc.364", "@trpc/server": "11.0.0-rc.364", + "csv-parse": "^5.5.6", "date-fns": "^3.6.0", "geist": "^1.3.0", "lucide-react": "^0.390.0", diff --git a/apps/web/src/app/_components/participant/data-table.tsx b/apps/web/src/app/_components/participant/data-table.tsx index 34c8c722..5590c3b4 100644 --- a/apps/web/src/app/_components/participant/data-table.tsx +++ b/apps/web/src/app/_components/participant/data-table.tsx @@ -27,7 +27,6 @@ import { ChevronsRight, FileJson, FileSpreadsheet, - FileText, MoreHorizontal, PencilLine, Trash2, @@ -69,10 +68,10 @@ import { } from "@sora-vp/ui/tooltip"; import { api } from "~/trpc/react"; -import { NewParticipant } from "./new-participant"; +import { SingleNewParticipant, UploadNewParticipant } from "./new-participant"; import { SuddenQr } from "./sudden-qr"; -const GlobalSystemAllowance = createContext(true); +export const GlobalSystemAllowance = createContext(true); const MonoFont = Space_Mono({ weight: "400", @@ -301,11 +300,8 @@ export function DataTable() {
- - + + + } + > +
+ + participantMutation.mutate(data), + )} + className="space-y-3" + > + ( + + Nama Peserta + + + + + Nama peserta yang akan masuk menjadi daftar pemilih tetap. + + + + )} + /> + ( + + Peserta Bagian Dari + + + + + Pengelompokan jenis peserta (siswa, guru, panitia, dll). + + + + )} + /> - if (!newValue) form.reset(); +
+ + + - return newValue; + +
+ + + + ); +} + +export function UploadNewParticipant(params: type) { + const [isOpen, setDialogOpen] = useState(false); + const [readLock, setReadLock] = useState(false); + + const apiUtils = api.useUtils(); + + const form = useForm({ + resolver: zodResolver(participant.UploadParticipantSchema), + }); + + const insertManyMutation = api.participant.insertManyParticipant.useMutation({ + onSuccess(result) { + toast.success("Berhasil mengunggah file csv!", { + description: "Seluruh peserta yang terdaftar berhasil ditambahkan!", + }); + + setDialogOpen(false); + }, + + onError(result) { + toast.error("Gagal mengunggah file csv.", { + description: result.message, + }); + }, + + async onSettled() { + setReadLock(false); + + await apiUtils.participant.getAllParticipants.invalidate(); + }, + }); + + async function onSubmit(values: UploadFormSchema) { + setReadLock(true); + + const file = values.csv.item(0)!; + const text = await file.text(); + + parseCSV(text, { columns: true, trim: true }, (err, records) => { + if (err) { + setReadLock(false); + + toast.error("Gagal Membaca File", { + description: `Terjadi kesalahan, Error: ${err.message}`, }); - }} - > - + + return; + } + + const result = participant.SharedUploadManyParticipant.safeParse(records); + + if (!result.success) { + toast.error("Format file tidak sesuai!", { + description: `Mohon periksa kembali format file yang ingin di upload, masih ada kesalahan.`, + }); + + setReadLock(false); + + return; + } + + insertManyMutation.mutate(result.data); + }); + } + + const setOpenCb = useCallback(() => setDialogOpen((prev) => !prev), []); + + return ( + - Tambah Partisipan Baru - + Unggah Partisipan (File CSV) + - - - - - Tambah Partisipan Baru - - - Masukan informasi peserta pemilihan yang baru. - - -
- - participantMutation.mutate(data), - )} - className="space-y-3" + } + > + + + ( + + File CSV + + + + + Pilih file csv untuk menambahkan banyak murid sekaligus. + + + + )} + /> + +
+ + + + + - - - -
- - -
-
- + Unggah + +
+ + + ); } diff --git a/packages/api/src/router/participant.ts b/packages/api/src/router/participant.ts index 25db630f..ec08f757 100644 --- a/packages/api/src/router/participant.ts +++ b/packages/api/src/router/participant.ts @@ -1,6 +1,7 @@ import type { TRPCRouterRecord } from "@trpc/server"; +import { TRPCError } from "@trpc/server"; -import { preparedGetAllParticipants, schema } from "@sora-vp/db"; +import { eq, preparedGetAllParticipants, schema } from "@sora-vp/db"; import { participant } from "@sora-vp/validators"; import { protectedProcedure } from "../trpc"; @@ -15,4 +16,46 @@ export const participantRouter = { .mutation(({ ctx, input }) => ctx.db.transaction((tx) => tx.insert(schema.participants).values(input)), ), + + insertManyParticipant: protectedProcedure + .input(participant.SharedUploadManyParticipant) + .mutation(({ input, ctx }) => + ctx.db.transaction(async (tx) => { + const okToInsert = input.map((data) => ({ + name: data.Nama, + subpart: data["Bagian Dari"], + })); + + const checkThing = await Promise.all( + okToInsert.map(({ name }) => + tx.query.participants.findFirst({ + where: eq(schema.participants.name, name), + }), + ), + ); + const normalizedCheckThing = checkThing.filter((d) => !!d); + + if ( + normalizedCheckThing.length > 0 && + normalizedCheckThing.every((data) => data !== null) + ) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Semua data yang ingin di upload sudah terdaftar!", + }); + } + + if ( + normalizedCheckThing.length > 0 && + normalizedCheckThing.some((data) => data !== null) + ) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Beberapa data yang ingin di upload sudah ada!", + }); + } + + return tx.insert(schema.participants).values(okToInsert); + }), + ), } satisfies TRPCRouterRecord; diff --git a/packages/validators/src/participant.ts b/packages/validators/src/participant.ts index 8ad9d5e0..2cddf4f3 100644 --- a/packages/validators/src/participant.ts +++ b/packages/validators/src/participant.ts @@ -21,7 +21,7 @@ const SharedAddPariticipant = z.object({ subpart: baseSubpartSchema, }); -const ServerUploadManyParticipant = z.array( +const SharedUploadManyParticipant = z.array( z.object({ Nama: baseNameSchema, "Bagian Dari": baseSubpartSchema }), ); @@ -35,8 +35,6 @@ const UploadParticipantSchema = z.object({ ), }); -// export type TUploadFormValues = { csv: FileList }; - const DeleteParticipantSchema = z.object({ id: z.number() }); const ParticipantAttendSchema = z.string().refine(validateId); @@ -49,7 +47,7 @@ const SharedUpdateParticipant = z.object({ export const participant = { SharedAddPariticipant, - ServerUploadManyParticipant, + SharedUploadManyParticipant, UploadParticipantSchema, DeleteParticipantSchema, ParticipantAttendSchema, diff --git a/yarn.lock b/yarn.lock index fb29e114..366a1f7d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2886,6 +2886,7 @@ __metadata: "@types/qrcode": "npm:^1" "@types/react": "npm:^18.3.1" "@types/react-dom": "npm:^18.3.0" + csv-parse: "npm:^5.5.6" date-fns: "npm:^3.6.0" dotenv-cli: "npm:^7.4.1" eslint: "npm:^9.2.0" @@ -4810,6 +4811,13 @@ __metadata: languageName: node linkType: hard +"csv-parse@npm:^5.5.6": + version: 5.5.6 + resolution: "csv-parse@npm:5.5.6" + checksum: 10c0/b4f6e9b885e4488829356455157bd009f3fed4119c5fbaadab1a879e85f0a9a1b62cd01e6de68ff77a50f820a6261722bce1b799da1ace2e2126e0b7c8d86760 + languageName: node + linkType: hard + "d@npm:1, d@npm:^1.0.1, d@npm:^1.0.2": version: 1.0.2 resolution: "d@npm:1.0.2" From aa1feecf94151160b53c9fb47e308096ef8ef891 Mon Sep 17 00:00:00 2001 From: Ezra Khairan Permana Date: Wed, 12 Jun 2024 20:41:50 +0700 Subject: [PATCH 29/44] feat: menambahkan fitur export --- apps/web/package.json | 1 + .../_components/participant/data-table.tsx | 13 +- .../app/_components/participant/export.tsx | 212 ++++++++ .../participant/new-participant.tsx | 2 +- packages/api/src/router/participant.ts | 49 +- packages/db/src/index.ts | 16 +- yarn.lock | 467 +++++++++++++++++- 7 files changed, 740 insertions(+), 20 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 38ffdb4f..bc0816ae 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -27,6 +27,7 @@ "@trpc/server": "11.0.0-rc.364", "csv-parse": "^5.5.6", "date-fns": "^3.6.0", + "exceljs": "^4.4.0", "geist": "^1.3.0", "lucide-react": "^0.390.0", "next": "^14.2.3", diff --git a/apps/web/src/app/_components/participant/data-table.tsx b/apps/web/src/app/_components/participant/data-table.tsx index 5590c3b4..8e99de59 100644 --- a/apps/web/src/app/_components/participant/data-table.tsx +++ b/apps/web/src/app/_components/participant/data-table.tsx @@ -68,10 +68,11 @@ import { } from "@sora-vp/ui/tooltip"; import { api } from "~/trpc/react"; +import { ExportJSON, ExportXLSX } from "./export"; import { SingleNewParticipant, UploadNewParticipant } from "./new-participant"; import { SuddenQr } from "./sudden-qr"; -export const GlobalSystemAllowance = createContext(true); +const GlobalSystemAllowance = createContext(true); const MonoFont = Space_Mono({ weight: "400", @@ -303,14 +304,8 @@ export function DataTable() { - - + +
diff --git a/apps/web/src/app/_components/participant/export.tsx b/apps/web/src/app/_components/participant/export.tsx index e69de29b..82713806 100644 --- a/apps/web/src/app/_components/participant/export.tsx +++ b/apps/web/src/app/_components/participant/export.tsx @@ -0,0 +1,212 @@ +import { useState } from "react"; +import ExcelJS from "exceljs"; +import { FileJson, FileSpreadsheet } from "lucide-react"; + +import { Button } from "@sora-vp/ui/button"; +import { toast } from "@sora-vp/ui/toast"; + +import { api } from "~/trpc/react"; + +export function ExportJSON() { + const exportJson = api.participant.exportJsonData.useMutation({ + onSuccess({ data }) { + toast.success("Berhasil mengunduh data json!", { + description: "Silahkan simpan dan gunakan untuk website penyebar QR.", + }); + + const element = document.createElement("a"); + + element.setAttribute( + "href", + "data:application/json;charset=utf-8," + encodeURIComponent(data), + ); + element.setAttribute("download", "data-partisipan.json"); + + element.click(); + }, + onError(result) { + toast.error("Gagal mengunduh data excel, coba lagi nanti.", { + description: result.message, + }); + }, + }); + + return ( + + ); +} + +export function ExportXLSX() { + const [additionalDisabled, setDisabled] = useState(false); + + const exportXlsx = + api.participant.exportXlsxForParticipantToRetrieve.useMutation({ + async onSuccess(result) { + setDisabled(true); + + toast.success("Berhasil mengunduh data excel!", { + description: + "Silahkan simpan dan sebarkan untuk seluruh pemilih tetap yang sudah terdaftar!", + }); + + const workbook = new ExcelJS.Workbook(); + + workbook.created = new Date(); + + // First processed worksheet + const firstWorksheet = workbook.addWorksheet("SEMUA DPT YANG VALID", { + views: [{ state: "frozen", ySplit: 1 }], + }); + + const firstWorksheetRowContent = ["Nama", "QR ID", "Bagian Dari"]; + firstWorksheet.addRow(firstWorksheetRowContent); + + const firstWorksheetRow = firstWorksheet.getRow(1); + + for (let i = 1; i <= firstWorksheetRowContent.length; i++) { + firstWorksheet.getColumn(i).alignment = { + vertical: "middle", + horizontal: i > 1 ? "center" : "left", + }; + + firstWorksheetRow.getCell(i).border = { + top: { style: "thin" }, + left: { style: "thin" }, + bottom: { style: "thin" }, + right: { style: "thin" }, + }; + } + + firstWorksheetRow.alignment = { + vertical: "middle", + horizontal: "center", + }; + firstWorksheetRow.font = { + bold: true, + }; + + result.nonFiltered.forEach((res, idx) => { + firstWorksheet.addRow([res.name, res.qrId, res.subpart]); + + const currentRow = firstWorksheet.getRow(idx + 2); + + currentRow.getCell(2).font = { + name: "Courier New", + bold: true, + }; + + for (let i = 1; i <= firstWorksheetRowContent.length; i++) { + currentRow.getCell(i).border = { + top: { style: "thin" }, + left: { style: "thin" }, + bottom: { style: "thin" }, + right: { style: "thin" }, + }; + } + }); + + firstWorksheet.getColumn(1).width = 40; + firstWorksheet.getColumn(2).width = 27; + firstWorksheet.getColumn(3).width = 13; + // End of first processed worksheet + + // Dynamic worksheet + const headerRow = ["Nama", "QR ID"]; + + for (const subresult of result.filteredSubparts) { + const worksheet = workbook.addWorksheet(subresult.subpart, { + views: [{ state: "frozen", ySplit: 1 }], + }); + + worksheet.addRow(headerRow); + + const firstRow = worksheet.getRow(1); + + for (let i = 1; i <= headerRow.length; i++) { + firstRow.getCell(i).alignment = { + vertical: "middle", + horizontal: i > 1 ? "center" : "left", + }; + + firstRow.getCell(i).border = { + top: { style: "thin" }, + left: { style: "thin" }, + bottom: { style: "thin" }, + right: { style: "thin" }, + }; + } + + worksheet.alignment = { + vertical: "middle", + horizontal: "center", + }; + worksheet.font = { + bold: true, + }; + + subresult.participants.forEach((res, idx) => { + worksheet.addRow([res.name, res.qrId]); + + const currentRow = worksheet.getRow(idx + 2); + + currentRow.getCell(2).font = { + name: "Courier New", + bold: true, + }; + + currentRow.getCell(2).alignment = { + vertical: "middle", + horizontal: "center", + }; + + for (let i = 1; i <= headerRow.length; i++) { + currentRow.getCell(i).border = { + top: { style: "thin" }, + left: { style: "thin" }, + bottom: { style: "thin" }, + right: { style: "thin" }, + }; + } + }); + + worksheet.getColumn(1).width = 40; + worksheet.getColumn(2).width = 27; + } + + const buffer = await workbook.xlsx.writeBuffer(); + + const blob = new Blob([buffer]); + const url = URL.createObjectURL(blob); + + const anchor = document.createElement("a"); + + anchor.href = url; + anchor.download = `${+Date.now()}-Data Seluruh Pemilih Tetap.xlsx`; + + anchor.click(); + anchor.remove(); + + setDisabled(false); + }, + onError(result) { + toast.error("Gagal mengunduh data json, coba lagi nanti.", { + description: result.message, + }); + + setDisabled(false); + }, + }); + + return ( + + ); +} diff --git a/apps/web/src/app/_components/participant/new-participant.tsx b/apps/web/src/app/_components/participant/new-participant.tsx index 20ae78fa..95d0309f 100644 --- a/apps/web/src/app/_components/participant/new-participant.tsx +++ b/apps/web/src/app/_components/participant/new-participant.tsx @@ -36,7 +36,7 @@ import { api } from "~/trpc/react"; type SingleFormSchema = z.infer; type UploadFormSchema = { csv: FileList }; -const ReusableDialog = memo(function MemoizedReusable({ +export const ReusableDialog = memo(function MemoizedReusable({ dialogOpen, setOpen, title, diff --git a/packages/api/src/router/participant.ts b/packages/api/src/router/participant.ts index ec08f757..84e1dc41 100644 --- a/packages/api/src/router/participant.ts +++ b/packages/api/src/router/participant.ts @@ -1,7 +1,12 @@ import type { TRPCRouterRecord } from "@trpc/server"; import { TRPCError } from "@trpc/server"; -import { eq, preparedGetAllParticipants, schema } from "@sora-vp/db"; +import { + eq, + preparedGetAllParticipants, + preparedGetExcelParticipants, + schema, +} from "@sora-vp/db"; import { participant } from "@sora-vp/validators"; import { protectedProcedure } from "../trpc"; @@ -58,4 +63,46 @@ export const participantRouter = { return tx.insert(schema.participants).values(okToInsert); }), ), + + exportJsonData: protectedProcedure.mutation(async () => { + const participants = await preparedGetExcelParticipants.execute(); + + if (!participants || participants.length < 0) + throw new TRPCError({ + code: "NOT_FOUND", + message: "Tidak ada data peserta pemilihan!", + }); + + return { data: JSON.stringify(participants, null, 2) }; + }), + + exportXlsxForParticipantToRetrieve: protectedProcedure.mutation(async () => { + const participants = await preparedGetExcelParticipants.execute(); + + if (!participants || participants.length < 1) + throw new TRPCError({ + code: "NOT_FOUND", + message: "Tidak ada data peserta pemilihan!", + }); + + const resorted = participants.sort((a, b) => { + if (a.subpart === b.subpart) return a.name.localeCompare(b.name); + + return a.subpart.localeCompare(b.subpart); + }); + + const filteredSubparts = [...new Set(resorted.map((d) => d.subpart))].map( + (subpart) => ({ + subpart, + participants: resorted + .filter((r) => r.subpart === subpart) + .map(({ subpart: _, ...rest }) => rest), + }), + ); + + return { + nonFiltered: resorted, + filteredSubparts, + }; + }), } satisfies TRPCRouterRecord; diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 03870cd6..b6d80749 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -30,5 +30,19 @@ export const countUserTable = db .prepare(); export const preparedGetAllParticipants = db.query.participants - .findMany() + .findMany({ + columns: { + id: false, + }, + }) + .prepare(); + +export const preparedGetExcelParticipants = db.query.participants + .findMany({ + columns: { + name: true, + qrId: true, + subpart: true, + }, + }) .prepare(); diff --git a/yarn.lock b/yarn.lock index 366a1f7d..8bd7a388 100644 --- a/yarn.lock +++ b/yarn.lock @@ -886,6 +886,35 @@ __metadata: languageName: node linkType: hard +"@fast-csv/format@npm:4.3.5": + version: 4.3.5 + resolution: "@fast-csv/format@npm:4.3.5" + dependencies: + "@types/node": "npm:^14.0.1" + lodash.escaperegexp: "npm:^4.1.2" + lodash.isboolean: "npm:^3.0.3" + lodash.isequal: "npm:^4.5.0" + lodash.isfunction: "npm:^3.0.9" + lodash.isnil: "npm:^4.0.0" + checksum: 10c0/06c6b3310edaf08033236539b93ebed027ea36f9f8a3cf42069034d4f75dff103a35930c9a9f01e2e344d8836fb2cc55a16affb5c345a8b3682d5a0cb031e0ea + languageName: node + linkType: hard + +"@fast-csv/parse@npm:4.3.6": + version: 4.3.6 + resolution: "@fast-csv/parse@npm:4.3.6" + dependencies: + "@types/node": "npm:^14.0.1" + lodash.escaperegexp: "npm:^4.1.2" + lodash.groupby: "npm:^4.6.0" + lodash.isfunction: "npm:^3.0.9" + lodash.isnil: "npm:^4.0.0" + lodash.isundefined: "npm:^3.0.1" + lodash.uniq: "npm:^4.5.0" + checksum: 10c0/dfd1834bfcea2665bd9db05b21793f79fd3502abdf955d6d63f7bf9724082f0b67a6379687b2945ca8d513b4d383a7bdaeed72a03cabd9191032b81448379917 + languageName: node + linkType: hard + "@fastify/busboy@npm:^2.0.0": version: 2.1.1 resolution: "@fastify/busboy@npm:2.1.1" @@ -2890,6 +2919,7 @@ __metadata: date-fns: "npm:^3.6.0" dotenv-cli: "npm:^7.4.1" eslint: "npm:^9.2.0" + exceljs: "npm:^4.4.0" geist: "npm:^1.3.0" jiti: "npm:^1.21.0" lucide-react: "npm:^0.390.0" @@ -3201,6 +3231,13 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^14.0.1": + version: 14.18.63 + resolution: "@types/node@npm:14.18.63" + checksum: 10c0/626a371419a6a0e11ca460b22bb4894abe5d75c303739588bc96267e380aa8b90ba5a87bc552400584f0ac2a84b5c458dadcbcf0dfd2396ebeb765f7a7f95893 + languageName: node + linkType: hard + "@types/prop-types@npm:*": version: 15.7.12 resolution: "@types/prop-types@npm:15.7.12" @@ -3784,6 +3821,42 @@ __metadata: languageName: node linkType: hard +"archiver-utils@npm:^2.1.0": + version: 2.1.0 + resolution: "archiver-utils@npm:2.1.0" + dependencies: + glob: "npm:^7.1.4" + graceful-fs: "npm:^4.2.0" + lazystream: "npm:^1.0.0" + lodash.defaults: "npm:^4.2.0" + lodash.difference: "npm:^4.5.0" + lodash.flatten: "npm:^4.4.0" + lodash.isplainobject: "npm:^4.0.6" + lodash.union: "npm:^4.6.0" + normalize-path: "npm:^3.0.0" + readable-stream: "npm:^2.0.0" + checksum: 10c0/6ea5b02e440f3099aff58b18dd384f84ecfe18632e81d26c1011fe7dfdb80ade43d7a06cbf048ef0e9ee0f2c87a80cb24c0f0ac5e3a2c4d67641d6f0d6e36ece + languageName: node + linkType: hard + +"archiver-utils@npm:^3.0.4": + version: 3.0.4 + resolution: "archiver-utils@npm:3.0.4" + dependencies: + glob: "npm:^7.2.3" + graceful-fs: "npm:^4.2.0" + lazystream: "npm:^1.0.0" + lodash.defaults: "npm:^4.2.0" + lodash.difference: "npm:^4.5.0" + lodash.flatten: "npm:^4.4.0" + lodash.isplainobject: "npm:^4.0.6" + lodash.union: "npm:^4.6.0" + normalize-path: "npm:^3.0.0" + readable-stream: "npm:^3.6.0" + checksum: 10c0/9bb7e271e95ff33bdbdcd6f69f8860e0aeed3fcba352a74f51a626d1c32b404f20e3185d5214f171b24a692471d01702f43874d1a4f0d2e5f57bd0834bc54c14 + languageName: node + linkType: hard + "archiver-utils@npm:^5.0.0, archiver-utils@npm:^5.0.2": version: 5.0.2 resolution: "archiver-utils@npm:5.0.2" @@ -3799,6 +3872,21 @@ __metadata: languageName: node linkType: hard +"archiver@npm:^5.0.0": + version: 5.3.2 + resolution: "archiver@npm:5.3.2" + dependencies: + archiver-utils: "npm:^2.1.0" + async: "npm:^3.2.4" + buffer-crc32: "npm:^0.2.1" + readable-stream: "npm:^3.6.0" + readdir-glob: "npm:^1.1.2" + tar-stream: "npm:^2.2.0" + zip-stream: "npm:^4.1.0" + checksum: 10c0/973384d749b3fa96f44ceda1603a65aaa3f24a267230d69a4df9d7b607d38d3ebc6c18c358af76eb06345b6b331ccb9eca07bd079430226b5afce95de22dfade + languageName: node + linkType: hard + "archiver@npm:^7.0.1": version: 7.0.1 resolution: "archiver@npm:7.0.1" @@ -4094,6 +4182,13 @@ __metadata: languageName: node linkType: hard +"big-integer@npm:^1.6.17": + version: 1.6.52 + resolution: "big-integer@npm:1.6.52" + checksum: 10c0/9604224b4c2ab3c43c075d92da15863077a9f59e5d4205f4e7e76acd0cd47e8d469ec5e5dba8d9b32aa233951893b29329ca56ac80c20ce094b4a647a66abae0 + languageName: node + linkType: hard + "binary-extensions@npm:^2.0.0": version: 2.3.0 resolution: "binary-extensions@npm:2.3.0" @@ -4101,6 +4196,16 @@ __metadata: languageName: node linkType: hard +"binary@npm:~0.3.0": + version: 0.3.0 + resolution: "binary@npm:0.3.0" + dependencies: + buffers: "npm:~0.1.1" + chainsaw: "npm:~0.1.0" + checksum: 10c0/752c2c2ff9f23506b3428cc8accbfcc92fec07bf8a31a1953e9c7e2193eb5db8a67252034ab93e8adab2a1c43f3eeb3da0bacae0320e9814f3ca127942c55871 + languageName: node + linkType: hard + "bindings@npm:^1.4.0": version: 1.5.0 resolution: "bindings@npm:1.5.0" @@ -4110,7 +4215,7 @@ __metadata: languageName: node linkType: hard -"bl@npm:^4.1.0": +"bl@npm:^4.0.3, bl@npm:^4.1.0": version: 4.1.0 resolution: "bl@npm:4.1.0" dependencies: @@ -4121,6 +4226,13 @@ __metadata: languageName: node linkType: hard +"bluebird@npm:~3.4.1": + version: 3.4.7 + resolution: "bluebird@npm:3.4.7" + checksum: 10c0/ac7e3df09a433b985a0ba61a0be4fc23e3874bf62440ffbca2f275a8498b00c11336f1f633631f38419b2c842515473985f9c4aaa9e4c9b36105535026d94144 + languageName: node + linkType: hard + "brace-expansion@npm:^1.1.7": version: 1.1.11 resolution: "brace-expansion@npm:1.1.11" @@ -4163,6 +4275,13 @@ __metadata: languageName: node linkType: hard +"buffer-crc32@npm:^0.2.1, buffer-crc32@npm:^0.2.13": + version: 0.2.13 + resolution: "buffer-crc32@npm:0.2.13" + checksum: 10c0/cb0a8ddf5cf4f766466db63279e47761eb825693eeba6a5a95ee4ec8cb8f81ede70aa7f9d8aeec083e781d47154290eb5d4d26b3f7a465ec57fb9e7d59c47150 + languageName: node + linkType: hard + "buffer-crc32@npm:^1.0.0": version: 1.0.0 resolution: "buffer-crc32@npm:1.0.0" @@ -4177,6 +4296,13 @@ __metadata: languageName: node linkType: hard +"buffer-indexof-polyfill@npm:~1.0.0": + version: 1.0.2 + resolution: "buffer-indexof-polyfill@npm:1.0.2" + checksum: 10c0/b8376d5f8b2c230d02fce36762b149b6c436aa03aca5e02b934ea13ce72a7e731c785fa30fb30e9c713df5173b4f8e89856574e70ce86b2f1d94d7d90166eab0 + languageName: node + linkType: hard + "buffer-more-ints@npm:~1.0.0": version: 1.0.0 resolution: "buffer-more-ints@npm:1.0.0" @@ -4204,6 +4330,13 @@ __metadata: languageName: node linkType: hard +"buffers@npm:~0.1.1": + version: 0.1.1 + resolution: "buffers@npm:0.1.1" + checksum: 10c0/c7a3284ddb4f5c65431508be65535e3739215f7996aa03e5d3a3fcf03144d35ffca7d9825572e6c6c6007f5308b8553c7b2941fcf5e56fac20dedea7178f5f71 + languageName: node + linkType: hard + "builtin-modules@npm:^3.3.0": version: 3.3.0 resolution: "builtin-modules@npm:3.3.0" @@ -4336,6 +4469,15 @@ __metadata: languageName: node linkType: hard +"chainsaw@npm:~0.1.0": + version: 0.1.0 + resolution: "chainsaw@npm:0.1.0" + dependencies: + traverse: "npm:>=0.3.0 <0.4" + checksum: 10c0/c27b8b10fd372b07d80b3f63615ce5ecb9bb1b0be6934fe5de3bb0328f9ffad5051f206bd7a0b426b85778fee0c063a1f029fb32cc639f3b2ee38d6b39f52c5c + languageName: node + linkType: hard + "chalk@npm:2.4.2, chalk@npm:^2.4.2": version: 2.4.2 resolution: "chalk@npm:2.4.2" @@ -4638,6 +4780,18 @@ __metadata: languageName: node linkType: hard +"compress-commons@npm:^4.1.2": + version: 4.1.2 + resolution: "compress-commons@npm:4.1.2" + dependencies: + buffer-crc32: "npm:^0.2.13" + crc32-stream: "npm:^4.0.2" + normalize-path: "npm:^3.0.0" + readable-stream: "npm:^3.6.0" + checksum: 10c0/e5fa03cb374ed89028e20226c70481e87286240392d5c6856f4e7fef40605c1892748648e20ed56597d390d76513b1b9bb4dbd658a1bbff41c9fa60107c74d3f + languageName: node + linkType: hard + "compress-commons@npm:^6.0.2": version: 6.0.2 resolution: "compress-commons@npm:6.0.2" @@ -4748,6 +4902,16 @@ __metadata: languageName: node linkType: hard +"crc32-stream@npm:^4.0.2": + version: 4.0.3 + resolution: "crc32-stream@npm:4.0.3" + dependencies: + crc-32: "npm:^1.2.0" + readable-stream: "npm:^3.4.0" + checksum: 10c0/127b0c66a947c54db37054fca86085722140644d3a75ebc61d4477bad19304d2936386b0461e8ee9e1c24b00e804cd7c2e205180e5bcb4632d20eccd60533bc4 + languageName: node + linkType: hard + "crc32-stream@npm:^6.0.0": version: 6.0.0 resolution: "crc32-stream@npm:6.0.0" @@ -4889,6 +5053,13 @@ __metadata: languageName: node linkType: hard +"dayjs@npm:^1.8.34": + version: 1.11.11 + resolution: "dayjs@npm:1.11.11" + checksum: 10c0/0131d10516b9945f05a57e13f4af49a6814de5573a494824e103131a3bbe4cc470b1aefe8e17e51f9a478a22cd116084be1ee5725cedb66ec4c3f9091202dc4b + languageName: node + linkType: hard + "db0@npm:^0.1.4": version: 0.1.4 resolution: "db0@npm:0.1.4" @@ -5330,6 +5501,15 @@ __metadata: languageName: node linkType: hard +"duplexer2@npm:~0.1.4": + version: 0.1.4 + resolution: "duplexer2@npm:0.1.4" + dependencies: + readable-stream: "npm:^2.0.2" + checksum: 10c0/0765a4cc6fe6d9615d43cc6dbccff6f8412811d89a6f6aa44828ca9422a0a469625ce023bf81cee68f52930dbedf9c5716056ff264ac886612702d134b5e39b4 + languageName: node + linkType: hard + "duplexer@npm:^0.1.2": version: 0.1.2 resolution: "duplexer@npm:0.1.2" @@ -5395,7 +5575,7 @@ __metadata: languageName: node linkType: hard -"end-of-stream@npm:^1.1.0": +"end-of-stream@npm:^1.1.0, end-of-stream@npm:^1.4.1": version: 1.4.4 resolution: "end-of-stream@npm:1.4.4" dependencies: @@ -6306,6 +6486,23 @@ __metadata: languageName: node linkType: hard +"exceljs@npm:^4.4.0": + version: 4.4.0 + resolution: "exceljs@npm:4.4.0" + dependencies: + archiver: "npm:^5.0.0" + dayjs: "npm:^1.8.34" + fast-csv: "npm:^4.3.1" + jszip: "npm:^3.10.1" + readable-stream: "npm:^3.6.0" + saxes: "npm:^5.0.1" + tmp: "npm:^0.2.0" + unzipper: "npm:^0.10.11" + uuid: "npm:^8.3.0" + checksum: 10c0/47aa3e2b1238719946b788bbe00ea7068e746df64697ec7b93662061f10c8081a69190f0c2110a69af8b0eedf26e40120479f4e93b8ce3957a83ab92dfe57f10 + languageName: node + linkType: hard + "execa@npm:5.1.1, execa@npm:^5.0.0": version: 5.1.1 resolution: "execa@npm:5.1.1" @@ -6374,6 +6571,16 @@ __metadata: languageName: node linkType: hard +"fast-csv@npm:^4.3.1": + version: 4.3.6 + resolution: "fast-csv@npm:4.3.6" + dependencies: + "@fast-csv/format": "npm:4.3.5" + "@fast-csv/parse": "npm:4.3.6" + checksum: 10c0/45118395a75dbb4fb3a074ee76f03abefe8414accbde6613ec3f326c3fef7098b8dc6297de65b6f15755f7520079aac58249cc939010033285161af8b1e007c9 + languageName: node + linkType: hard + "fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": version: 3.1.3 resolution: "fast-deep-equal@npm:3.1.3" @@ -6535,6 +6742,13 @@ __metadata: languageName: node linkType: hard +"fs-constants@npm:^1.0.0": + version: 1.0.0 + resolution: "fs-constants@npm:1.0.0" + checksum: 10c0/a0cde99085f0872f4d244e83e03a46aa387b74f5a5af750896c6b05e9077fac00e9932fdf5aef84f2f16634cd473c63037d7a512576da7d5c2b9163d1909f3a8 + languageName: node + linkType: hard + "fs-extra@npm:^10.1.0": version: 10.1.0 resolution: "fs-extra@npm:10.1.0" @@ -6601,6 +6815,18 @@ __metadata: languageName: node linkType: hard +"fstream@npm:^1.0.12": + version: 1.0.12 + resolution: "fstream@npm:1.0.12" + dependencies: + graceful-fs: "npm:^4.1.2" + inherits: "npm:~2.0.0" + mkdirp: "npm:>=0.5 0" + rimraf: "npm:2" + checksum: 10c0/f52a0687a0649c6b93973eb7f1d5495e445fa993f797ba1af186e666b6aebe53916a8c497dce7037c74d0d4a33c56b0ab1f98f10ad71cca84ba8661110d25ee2 + languageName: node + linkType: hard + "function-bind@npm:^1.1.2": version: 1.1.2 resolution: "function-bind@npm:1.1.2" @@ -6838,7 +7064,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^7.1.3": +"glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.2.3": version: 7.2.3 resolution: "glob@npm:7.2.3" dependencies: @@ -6942,7 +7168,7 @@ __metadata: languageName: node linkType: hard -"graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.2, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": +"graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.2, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 @@ -7238,6 +7464,13 @@ __metadata: languageName: node linkType: hard +"immediate@npm:~3.0.5": + version: 3.0.6 + resolution: "immediate@npm:3.0.6" + checksum: 10c0/f8ba7ede69bee9260241ad078d2d535848745ff5f6995c7c7cb41cfdc9ccc213f66e10fa5afb881f90298b24a3f7344b637b592beb4f54e582770cdce3f1f039 + languageName: node + linkType: hard + "import-fresh@npm:^3.2.1": version: 3.3.0 resolution: "import-fresh@npm:3.3.0" @@ -7272,7 +7505,7 @@ __metadata: languageName: node linkType: hard -"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.1, inherits@npm:~2.0.3": +"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.0, inherits@npm:~2.0.1, inherits@npm:~2.0.3": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2 @@ -8002,6 +8235,18 @@ __metadata: languageName: node linkType: hard +"jszip@npm:^3.10.1": + version: 3.10.1 + resolution: "jszip@npm:3.10.1" + dependencies: + lie: "npm:~3.3.0" + pako: "npm:~1.0.2" + readable-stream: "npm:~2.3.6" + setimmediate: "npm:^1.0.5" + checksum: 10c0/58e01ec9c4960383fb8b38dd5f67b83ccc1ec215bf74c8a5b32f42b6e5fb79fada5176842a11409c4051b5b94275044851814a31076bf49e1be218d3ef57c863 + languageName: node + linkType: hard + "keyv@npm:^4.5.4": version: 4.5.4 resolution: "keyv@npm:4.5.4" @@ -8060,6 +8305,15 @@ __metadata: languageName: node linkType: hard +"lie@npm:~3.3.0": + version: 3.3.0 + resolution: "lie@npm:3.3.0" + dependencies: + immediate: "npm:~3.0.5" + checksum: 10c0/56dd113091978f82f9dc5081769c6f3b947852ecf9feccaf83e14a123bc630c2301439ce6182521e5fbafbde88e88ac38314327a4e0493a1bea7e0699a7af808 + languageName: node + linkType: hard + "lilconfig@npm:^2.1.0": version: 2.1.0 resolution: "lilconfig@npm:2.1.0" @@ -8081,6 +8335,13 @@ __metadata: languageName: node linkType: hard +"listenercount@npm:~1.0.1": + version: 1.0.1 + resolution: "listenercount@npm:1.0.1" + checksum: 10c0/280c38501984f0a83272187ea472aff18a2aa3db40d8e05be5f797dc813c3d9351ae67a64e09d23d36e6061288b291c989390297db6a99674de2394c6930284c + languageName: node + linkType: hard + "listhen@npm:^1.7.2": version: 1.7.2 resolution: "listhen@npm:1.7.2" @@ -8152,6 +8413,27 @@ __metadata: languageName: node linkType: hard +"lodash.difference@npm:^4.5.0": + version: 4.5.0 + resolution: "lodash.difference@npm:4.5.0" + checksum: 10c0/5d52859218a7df427547ff1fadbc397879709fe6c788b037df7d6d92b676122c92bd35ec85d364edb596b65dfc6573132f420c9b4ee22bb6b9600cd454c90637 + languageName: node + linkType: hard + +"lodash.escaperegexp@npm:^4.1.2": + version: 4.1.2 + resolution: "lodash.escaperegexp@npm:4.1.2" + checksum: 10c0/484ad4067fa9119bb0f7c19a36ab143d0173a081314993fe977bd00cf2a3c6a487ce417a10f6bac598d968364f992153315f0dbe25c9e38e3eb7581dd333e087 + languageName: node + linkType: hard + +"lodash.flatten@npm:^4.4.0": + version: 4.4.0 + resolution: "lodash.flatten@npm:4.4.0" + checksum: 10c0/97e8f0d6b61fe4723c02ad0c6e67e51784c4a2c48f56ef283483e556ad01594cf9cec9c773e177bbbdbdb5d19e99b09d2487cb6b6e5dc405c2693e93b125bd3a + languageName: node + linkType: hard + "lodash.get@npm:^4.4.2": version: 4.4.2 resolution: "lodash.get@npm:4.4.2" @@ -8159,6 +8441,13 @@ __metadata: languageName: node linkType: hard +"lodash.groupby@npm:^4.6.0": + version: 4.6.0 + resolution: "lodash.groupby@npm:4.6.0" + checksum: 10c0/3d136cad438ad6c3a078984ef60e057a3498b1312aa3621b00246ecb99e8f2c4d447e2815460db7a0b661a4fe4e2eeee96c84cb661a824bad04b6cf1f7bc6e9b + languageName: node + linkType: hard + "lodash.isarguments@npm:^3.1.0": version: 3.1.0 resolution: "lodash.isarguments@npm:3.1.0" @@ -8166,6 +8455,48 @@ __metadata: languageName: node linkType: hard +"lodash.isboolean@npm:^3.0.3": + version: 3.0.3 + resolution: "lodash.isboolean@npm:3.0.3" + checksum: 10c0/0aac604c1ef7e72f9a6b798e5b676606042401dd58e49f051df3cc1e3adb497b3d7695635a5cbec4ae5f66456b951fdabe7d6b387055f13267cde521f10ec7f7 + languageName: node + linkType: hard + +"lodash.isequal@npm:^4.5.0": + version: 4.5.0 + resolution: "lodash.isequal@npm:4.5.0" + checksum: 10c0/dfdb2356db19631a4b445d5f37868a095e2402292d59539a987f134a8778c62a2810c2452d11ae9e6dcac71fc9de40a6fedcb20e2952a15b431ad8b29e50e28f + languageName: node + linkType: hard + +"lodash.isfunction@npm:^3.0.9": + version: 3.0.9 + resolution: "lodash.isfunction@npm:3.0.9" + checksum: 10c0/e88620922f5f104819496884779ca85bfc542efb2946df661ab3e2cd38da5c8375434c6adbedfc76dd3c2b04075d2ba8ec215cfdedf08ddd2e3c3467e8a26ccd + languageName: node + linkType: hard + +"lodash.isnil@npm:^4.0.0": + version: 4.0.0 + resolution: "lodash.isnil@npm:4.0.0" + checksum: 10c0/1a410a62eb2e797f077d038c11cbf1ea18ab36f713982849f086f86e050234d69988c76fa18d00278c0947daec67e9ecbc666326b8a06b43e36d3ece813a8120 + languageName: node + linkType: hard + +"lodash.isplainobject@npm:^4.0.6": + version: 4.0.6 + resolution: "lodash.isplainobject@npm:4.0.6" + checksum: 10c0/afd70b5c450d1e09f32a737bed06ff85b873ecd3d3d3400458725283e3f2e0bb6bf48e67dbe7a309eb371a822b16a26cca4a63c8c52db3fc7dc9d5f9dd324cbb + languageName: node + linkType: hard + +"lodash.isundefined@npm:^3.0.1": + version: 3.0.1 + resolution: "lodash.isundefined@npm:3.0.1" + checksum: 10c0/00ca2ae6fc83e10f806769130ee62b5bf419a4aaa52d1a084164b4cf2b2ab1dbf7246e05c72cf0df2ebf4ea38ab565a688c1a7362b54331bb336ea8b492f327f + languageName: node + linkType: hard + "lodash.merge@npm:^4.6.2": version: 4.6.2 resolution: "lodash.merge@npm:4.6.2" @@ -8187,6 +8518,20 @@ __metadata: languageName: node linkType: hard +"lodash.union@npm:^4.6.0": + version: 4.6.0 + resolution: "lodash.union@npm:4.6.0" + checksum: 10c0/6da7f72d1facd472f6090b49eefff984c9f9179e13172039c0debca6851d21d37d83c7ad5c43af23bd220f184cd80e6897e8e3206509fae491f9068b02ae6319 + languageName: node + linkType: hard + +"lodash.uniq@npm:^4.5.0": + version: 4.5.0 + resolution: "lodash.uniq@npm:4.5.0" + checksum: 10c0/262d400bb0952f112162a320cc4a75dea4f66078b9e7e3075ffbc9c6aa30b3e9df3cf20e7da7d566105e1ccf7804e4fbd7d804eee0b53de05d83f16ffbf41c5e + languageName: node + linkType: hard + "lodash@npm:^4.17.15, lodash@npm:^4.17.19, lodash@npm:^4.17.21": version: 4.17.21 resolution: "lodash@npm:4.17.21" @@ -8557,7 +8902,7 @@ __metadata: languageName: node linkType: hard -"mkdirp@npm:^0.5.1": +"mkdirp@npm:>=0.5 0, mkdirp@npm:^0.5.1": version: 0.5.6 resolution: "mkdirp@npm:0.5.6" dependencies: @@ -9425,6 +9770,13 @@ __metadata: languageName: node linkType: hard +"pako@npm:~1.0.2": + version: 1.0.11 + resolution: "pako@npm:1.0.11" + checksum: 10c0/86dd99d8b34c3930345b8bbeb5e1cd8a05f608eeb40967b293f72fe469d0e9c88b783a8777e4cc7dc7c91ce54c5e93d88ff4b4f060e6ff18408fd21030d9ffbe + languageName: node + linkType: hard + "param-case@npm:^2.1.0": version: 2.1.1 resolution: "param-case@npm:2.1.1" @@ -10157,7 +10509,7 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:^2.0.5": +"readable-stream@npm:^2.0.0, readable-stream@npm:^2.0.2, readable-stream@npm:^2.0.5, readable-stream@npm:~2.3.6": version: 2.3.8 resolution: "readable-stream@npm:2.3.8" dependencies: @@ -10172,7 +10524,7 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:^3.4.0, readable-stream@npm:^3.6.0": +"readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0, readable-stream@npm:^3.6.0": version: 3.6.2 resolution: "readable-stream@npm:3.6.2" dependencies: @@ -10408,6 +10760,17 @@ __metadata: languageName: node linkType: hard +"rimraf@npm:2": + version: 2.7.1 + resolution: "rimraf@npm:2.7.1" + dependencies: + glob: "npm:^7.1.3" + bin: + rimraf: ./bin.js + checksum: 10c0/4eef73d406c6940927479a3a9dee551e14a54faf54b31ef861250ac815172bade86cc6f7d64a4dc5e98b65e4b18a2e1c9ff3b68d296be0c748413f092bb0dd40 + languageName: node + linkType: hard + "rimraf@npm:^3.0.0, rimraf@npm:^3.0.2": version: 3.0.2 resolution: "rimraf@npm:3.0.2" @@ -10586,6 +10949,15 @@ __metadata: languageName: node linkType: hard +"saxes@npm:^5.0.1": + version: 5.0.1 + resolution: "saxes@npm:5.0.1" + dependencies: + xmlchars: "npm:^2.2.0" + checksum: 10c0/b7476c41dbe1c3a89907d2546fecfba234de5e66743ef914cde2603f47b19bed09732ab51b528ad0f98b958369d8be72b6f5af5c9cfad69972a73d061f0b3952 + languageName: node + linkType: hard + "scheduler@npm:^0.23.2": version: 0.23.2 resolution: "scheduler@npm:0.23.2" @@ -10728,6 +11100,13 @@ __metadata: languageName: node linkType: hard +"setimmediate@npm:^1.0.5, setimmediate@npm:~1.0.4": + version: 1.0.5 + resolution: "setimmediate@npm:1.0.5" + checksum: 10c0/5bae81bfdbfbd0ce992893286d49c9693c82b1bcc00dcaaf3a09c8f428fdeacf4190c013598b81875dfac2b08a572422db7df779a99332d0fce186d15a3e4d49 + languageName: node + linkType: hard + "setprototypeof@npm:1.2.0": version: 1.2.0 resolution: "setprototypeof@npm:1.2.0" @@ -11312,6 +11691,19 @@ __metadata: languageName: node linkType: hard +"tar-stream@npm:^2.2.0": + version: 2.2.0 + resolution: "tar-stream@npm:2.2.0" + dependencies: + bl: "npm:^4.0.3" + end-of-stream: "npm:^1.4.1" + fs-constants: "npm:^1.0.0" + inherits: "npm:^2.0.3" + readable-stream: "npm:^3.1.1" + checksum: 10c0/2f4c910b3ee7196502e1ff015a7ba321ec6ea837667220d7bcb8d0852d51cb04b87f7ae471008a6fb8f5b1a1b5078f62f3a82d30c706f20ada1238ac797e7692 + languageName: node + linkType: hard + "tar-stream@npm:^3.0.0": version: 3.1.7 resolution: "tar-stream@npm:3.1.7" @@ -11438,6 +11830,13 @@ __metadata: languageName: node linkType: hard +"tmp@npm:^0.2.0": + version: 0.2.3 + resolution: "tmp@npm:0.2.3" + checksum: 10c0/3e809d9c2f46817475b452725c2aaa5d11985cf18d32a7a970ff25b568438e2c076c2e8609224feef3b7923fa9749b74428e3e634f6b8e520c534eef2fd24125 + languageName: node + linkType: hard + "to-fast-properties@npm:^2.0.0": version: 2.0.0 resolution: "to-fast-properties@npm:2.0.0" @@ -11477,6 +11876,13 @@ __metadata: languageName: node linkType: hard +"traverse@npm:>=0.3.0 <0.4": + version: 0.3.9 + resolution: "traverse@npm:0.3.9" + checksum: 10c0/05f04ff1002f08f19b033187124764e2713186c7a7c0ad88172368df993edc4fa7580e829e252cef6b38375317b69671932ee3820381398a9e375aad3797f607 + languageName: node + linkType: hard + "tree-kill@npm:^1.2.2": version: 1.2.2 resolution: "tree-kill@npm:1.2.2" @@ -12048,6 +12454,24 @@ __metadata: languageName: node linkType: hard +"unzipper@npm:^0.10.11": + version: 0.10.14 + resolution: "unzipper@npm:0.10.14" + dependencies: + big-integer: "npm:^1.6.17" + binary: "npm:~0.3.0" + bluebird: "npm:~3.4.1" + buffer-indexof-polyfill: "npm:~1.0.0" + duplexer2: "npm:~0.1.4" + fstream: "npm:^1.0.12" + graceful-fs: "npm:^4.2.2" + listenercount: "npm:~1.0.1" + readable-stream: "npm:~2.3.6" + setimmediate: "npm:~1.0.4" + checksum: 10c0/0d9d0bdb566581534fba4ad88cbf037f3c1d9aa97fcd26ca52d30e7e198a3c6cb9e315deadc59821647c98657f233601cb9ebfc92f59228a1fe594197061760e + languageName: node + linkType: hard + "update-browserslist-db@npm:^1.0.13": version: 1.0.15 resolution: "update-browserslist-db@npm:1.0.15" @@ -12159,6 +12583,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:^8.3.0": + version: 8.3.2 + resolution: "uuid@npm:8.3.2" + bin: + uuid: dist/bin/uuid + checksum: 10c0/bcbb807a917d374a49f475fae2e87fdca7da5e5530820ef53f65ba1d12131bd81a92ecf259cc7ce317cbe0f289e7d79fdfebcef9bfa3087c8c8a2fa304c9be54 + languageName: node + linkType: hard + "v8-compile-cache-lib@npm:^3.0.1": version: 3.0.1 resolution: "v8-compile-cache-lib@npm:3.0.1" @@ -12381,6 +12814,13 @@ __metadata: languageName: node linkType: hard +"xmlchars@npm:^2.2.0": + version: 2.2.0 + resolution: "xmlchars@npm:2.2.0" + checksum: 10c0/b64b535861a6f310c5d9bfa10834cf49127c71922c297da9d4d1b45eeaae40bf9b4363275876088fbe2667e5db028d2cd4f8ee72eed9bede840a67d57dab7593 + languageName: node + linkType: hard + "y18n@npm:^4.0.0": version: 4.0.3 resolution: "y18n@npm:4.0.3" @@ -12483,6 +12923,17 @@ __metadata: languageName: node linkType: hard +"zip-stream@npm:^4.1.0": + version: 4.1.1 + resolution: "zip-stream@npm:4.1.1" + dependencies: + archiver-utils: "npm:^3.0.4" + compress-commons: "npm:^4.1.2" + readable-stream: "npm:^3.6.0" + checksum: 10c0/38f91ca116a38561cf184c29e035e9453b12c30eaf574e0993107a4a5331882b58c9a7f7b97f63910664028089fbde3296d0b3682d1ccb2ad96929e68f1b2b89 + languageName: node + linkType: hard + "zip-stream@npm:^6.0.1": version: 6.0.1 resolution: "zip-stream@npm:6.0.1" From 82146ffbe5805a85b606ffdb2296805cd0dbf928 Mon Sep 17 00:00:00 2001 From: Ezra Khairan Permana Date: Wed, 12 Jun 2024 23:13:43 +0700 Subject: [PATCH 30/44] feat: fitur ubah identitas peserta --- .../_components/participant/csv-upload.tsx | 0 .../_components/participant/data-table.tsx | 40 +++- .../app/_components/participant/export.tsx | 2 +- .../participant/new-participant.tsx | 12 +- .../participant/participant-action.tsx | 175 ++++++++++++++++++ packages/api/src/router/participant.ts | 26 ++- packages/validators/src/participant.ts | 8 +- 7 files changed, 248 insertions(+), 15 deletions(-) delete mode 100644 apps/web/src/app/_components/participant/csv-upload.tsx create mode 100644 apps/web/src/app/_components/participant/participant-action.tsx diff --git a/apps/web/src/app/_components/participant/csv-upload.tsx b/apps/web/src/app/_components/participant/csv-upload.tsx deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/web/src/app/_components/participant/data-table.tsx b/apps/web/src/app/_components/participant/data-table.tsx index 8e99de59..f4e05ae4 100644 --- a/apps/web/src/app/_components/participant/data-table.tsx +++ b/apps/web/src/app/_components/participant/data-table.tsx @@ -6,7 +6,13 @@ import type { SortingState, VisibilityState, } from "@tanstack/react-table"; -import { createContext, useContext, useEffect, useState } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react"; import { Space_Mono } from "next/font/google"; import { flexRender, @@ -70,6 +76,7 @@ import { import { api } from "~/trpc/react"; import { ExportJSON, ExportXLSX } from "./export"; import { SingleNewParticipant, UploadNewParticipant } from "./new-participant"; +import { DeleteParticipant, EditParticipant } from "./participant-action"; import { SuddenQr } from "./sudden-qr"; const GlobalSystemAllowance = createContext(true); @@ -203,14 +210,22 @@ const columns: ColumnDef[] = [ cell: ({ row }) => { const participant = row.original; + // eslint-disable-next-line react-hooks/rules-of-hooks + const [openEdit, setOpenEdit] = useState(false); + // eslint-disable-next-line react-hooks/rules-of-hooks const [openDelete, setOpenDelete] = useState(false); // eslint-disable-next-line react-hooks/rules-of-hooks const globallyAllowedToOpen = useContext(GlobalSystemAllowance); - // // eslint-disable-next-line react-hooks/rules-of-hooks - // const closeDialog = useCallback(() => setOpenDelete((prev) => !prev), []); + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + if (!globallyAllowedToOpen) { + setOpenEdit(false); + setOpenDelete(false); + } + }, [globallyAllowedToOpen]); return ( <> @@ -230,7 +245,10 @@ const columns: ColumnDef[] = [ Aksi - + setOpenEdit(true)} + > Ubah Identitas @@ -244,6 +262,20 @@ const columns: ColumnDef[] = [ + + + ); }, diff --git a/apps/web/src/app/_components/participant/export.tsx b/apps/web/src/app/_components/participant/export.tsx index 82713806..3f2ced3d 100644 --- a/apps/web/src/app/_components/participant/export.tsx +++ b/apps/web/src/app/_components/participant/export.tsx @@ -49,7 +49,7 @@ export function ExportXLSX() { toast.success("Berhasil mengunduh data excel!", { description: - "Silahkan simpan dan sebarkan untuk seluruh pemilih tetap yang sudah terdaftar!", + "Silahkan simpan dan sebarkan untuk seluruh pemilih tetap yang sudah terdaftar.", }); const workbook = new ExcelJS.Workbook(); diff --git a/apps/web/src/app/_components/participant/new-participant.tsx b/apps/web/src/app/_components/participant/new-participant.tsx index 95d0309f..8cfc486d 100644 --- a/apps/web/src/app/_components/participant/new-participant.tsx +++ b/apps/web/src/app/_components/participant/new-participant.tsx @@ -1,6 +1,6 @@ "use client"; -import { memo, useCallback, useEffect, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useState } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; import { parse as parseCSV } from "csv-parse"; import { FileText, UserPlus } from "lucide-react"; @@ -33,7 +33,7 @@ import { participant } from "@sora-vp/validators"; import { api } from "~/trpc/react"; -type SingleFormSchema = z.infer; +type SingleFormSchema = z.infer; type UploadFormSchema = { csv: FileList }; export const ReusableDialog = memo(function MemoizedReusable({ @@ -48,12 +48,14 @@ export const ReusableDialog = memo(function MemoizedReusable({ setOpen: () => void; title: string; description: string; - dialogTrigger: React.ReactNode; + dialogTrigger?: React.ReactNode; children: React.ReactNode; }) { return ( - {dialogTrigger} + {dialogTrigger ? ( + {dialogTrigger} + ) : null} {title} @@ -72,7 +74,7 @@ export function SingleNewParticipant() { const apiUtils = api.useUtils(); const form = useForm({ - resolver: zodResolver(participant.SharedAddPariticipant), + resolver: zodResolver(participant.SharedAddParticipant), defaultValues: { name: "", subpart: "", diff --git a/apps/web/src/app/_components/participant/participant-action.tsx b/apps/web/src/app/_components/participant/participant-action.tsx new file mode 100644 index 00000000..a25930b4 --- /dev/null +++ b/apps/web/src/app/_components/participant/participant-action.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { useMemo } from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { FileText, UserPlus } from "lucide-react"; +import { useForm, useWatch } from "react-hook-form"; +import { z } from "zod"; + +import { Button } from "@sora-vp/ui/button"; +import { DialogClose } from "@sora-vp/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@sora-vp/ui/form"; +import { Input } from "@sora-vp/ui/input"; +import { toast } from "@sora-vp/ui/toast"; +import { participant } from "@sora-vp/validators"; + +import { api } from "~/trpc/react"; +import { ReusableDialog } from "./new-participant"; + +interface IProps { + dialogOpen: boolean; + openSetter: React.Dispatch>; + name: string; + qrId: string; + subpart?: string; +} + +type EditFormSchema = z.infer; + +export function EditParticipant(props: IProps) { + const apiUtils = api.useUtils(); + + const form = useForm({ + resolver: zodResolver(participant.SharedAddParticipant), + defaultValues: { + name: props.name, + subpart: props.subpart!, + }, + }); + + const participantMutation = api.participant.updateParticipant.useMutation({ + onSuccess() { + toast.success("Operasi pengubahan berhasil!", { + description: "Berhasil mengubah pemilih tetap.", + }); + + props.openSetter(false); + }, + + onError(result) { + toast.error("Gagal mengubah peserta, coba lagi nanti.", { + description: result.message, + }); + }, + + async onSettled() { + await apiUtils.participant.getAllParticipants.invalidate(); + }, + }); + + const currentName = useWatch({ control: form.control, name: "name" }); + const currentSubpart = useWatch({ control: form.control, name: "subpart" }); + + const stillTheSameValue = useMemo( + () => currentName === props.name && currentSubpart === props.subpart, + [currentName, currentSubpart, props.name, props.subpart], + ); + + return ( + { + if (!participant.isPending) + props.openSetter((prev) => { + const newValue = !prev; + + if (!newValue) setTimeout(() => form.reset(), 1000); + + return newValue; + }); + }} + title="Perbaiki Identitas" + description="Perbarui identitas peserta yang mungkin salah dalam penulisan nama ataupun bagian dari peserta." + > +
+ + participantMutation.mutate({ ...data, qrId: props.qrId }), + )} + className="space-y-5" + > +
+ ( + + Nama Peserta + + + + + Nama peserta yang akan masuk menjadi daftar pemilih tetap. + + + + )} + /> + ( + + Peserta Bagian Dari + + + + + Pengelompokan jenis peserta (siswa, guru, panitia, dll). + + + + )} + /> +
+ +
+ + + + + +
+
+ +
+ ); +} + +export function DeleteParticipant(props: IProps) { + return ( + props.openSetter((prev) => !prev)} + title="Apakah anda yakin?" + description={`Aksi yang anda lakukan dapat berakibat fatal. Jika anda melakukan hal ini, maka akan secara permanen menghapus data peserta bernama ${props.name}.`} + > + ); +} diff --git a/packages/api/src/router/participant.ts b/packages/api/src/router/participant.ts index 84e1dc41..c51871a9 100644 --- a/packages/api/src/router/participant.ts +++ b/packages/api/src/router/participant.ts @@ -17,7 +17,7 @@ export const participantRouter = { ), createNewParticipant: protectedProcedure - .input(participant.SharedAddPariticipant) + .input(participant.SharedAddParticipant) .mutation(({ ctx, input }) => ctx.db.transaction((tx) => tx.insert(schema.participants).values(input)), ), @@ -64,6 +64,30 @@ export const participantRouter = { }), ), + updateParticipant: protectedProcedure + .input(participant.ServerUpdateParticipant) + .mutation(({ input, ctx }) => + ctx.db.transaction(async (tx) => { + const participant = await tx.query.participants.findFirst({ + where: eq(schema.participants.qrId, input.qrId), + }); + + if (!participant) + throw new TRPCError({ + code: "NOT_FOUND", + message: "Peserta pemilihan tidak dapat ditemukan!", + }); + + return await tx + .update(schema.participants) + .set({ + name: input.name, + subpart: input.subpart, + }) + .where(eq(schema.participants.qrId, input.qrId)); + }), + ), + exportJsonData: protectedProcedure.mutation(async () => { const participants = await preparedGetExcelParticipants.execute(); diff --git a/packages/validators/src/participant.ts b/packages/validators/src/participant.ts index 2cddf4f3..bc2f8313 100644 --- a/packages/validators/src/participant.ts +++ b/packages/validators/src/participant.ts @@ -16,7 +16,7 @@ const baseSubpartSchema = z message: "Hanya diperbolehkan menulis alfabet, angka, dan garis bawah!", }); -const SharedAddPariticipant = z.object({ +const SharedAddParticipant = z.object({ name: baseNameSchema, subpart: baseSubpartSchema, }); @@ -39,17 +39,17 @@ const DeleteParticipantSchema = z.object({ id: z.number() }); const ParticipantAttendSchema = z.string().refine(validateId); -const SharedUpdateParticipant = z.object({ +const ServerUpdateParticipant = z.object({ name: baseNameSchema, subpart: baseSubpartSchema, qrId: ParticipantAttendSchema, }); export const participant = { - SharedAddPariticipant, + SharedAddParticipant, SharedUploadManyParticipant, UploadParticipantSchema, DeleteParticipantSchema, ParticipantAttendSchema, - SharedUpdateParticipant, + ServerUpdateParticipant, } as const; From 7a8b7b780f788dc811d374e0f8ec5e943872e443 Mon Sep 17 00:00:00 2001 From: Ezra Khairan Permana Date: Wed, 12 Jun 2024 23:49:26 +0700 Subject: [PATCH 31/44] feat: fitur hapus peserta --- .../participant/participant-action.tsx | 132 ++++++++++++++---- packages/api/src/router/participant.ts | 39 ++++++ packages/validators/src/participant.ts | 6 +- 3 files changed, 146 insertions(+), 31 deletions(-) diff --git a/apps/web/src/app/_components/participant/participant-action.tsx b/apps/web/src/app/_components/participant/participant-action.tsx index a25930b4..9636fe49 100644 --- a/apps/web/src/app/_components/participant/participant-action.tsx +++ b/apps/web/src/app/_components/participant/participant-action.tsx @@ -1,13 +1,13 @@ "use client"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; import { FileText, UserPlus } from "lucide-react"; import { useForm, useWatch } from "react-hook-form"; import { z } from "zod"; import { Button } from "@sora-vp/ui/button"; -import { DialogClose } from "@sora-vp/ui/dialog"; +import { DialogClose, DialogDescription } from "@sora-vp/ui/dialog"; import { Form, FormControl, @@ -45,25 +45,27 @@ export function EditParticipant(props: IProps) { }, }); - const participantMutation = api.participant.updateParticipant.useMutation({ - onSuccess() { - toast.success("Operasi pengubahan berhasil!", { - description: "Berhasil mengubah pemilih tetap.", - }); - - props.openSetter(false); - }, - - onError(result) { - toast.error("Gagal mengubah peserta, coba lagi nanti.", { - description: result.message, - }); + const participantEditMutation = api.participant.updateParticipant.useMutation( + { + onSuccess() { + toast.success("Operasi pengubahan berhasil!", { + description: "Berhasil mengubah pemilih tetap.", + }); + + props.openSetter(false); + }, + + onError(result) { + toast.error("Gagal mengubah peserta, coba lagi nanti.", { + description: result.message, + }); + }, + + async onSettled() { + await apiUtils.participant.getAllParticipants.invalidate(); + }, }, - - async onSettled() { - await apiUtils.participant.getAllParticipants.invalidate(); - }, - }); + ); const currentName = useWatch({ control: form.control, name: "name" }); const currentSubpart = useWatch({ control: form.control, name: "subpart" }); @@ -75,7 +77,7 @@ export function EditParticipant(props: IProps) { return ( { if (!participant.isPending) props.openSetter((prev) => { @@ -92,7 +94,7 @@ export function EditParticipant(props: IProps) {
- participantMutation.mutate({ ...data, qrId: props.qrId }), + participantEditMutation.mutate({ ...data, qrId: props.qrId }), )} className="space-y-5" > @@ -106,7 +108,7 @@ export function EditParticipant(props: IProps) { @@ -125,7 +127,7 @@ export function EditParticipant(props: IProps) { @@ -143,7 +145,7 @@ export function EditParticipant(props: IProps) { className="md:ml-auto md:w-fit" type="button" variant="secondary" - disabled={participantMutation.isPending} + disabled={participantEditMutation.isPending} > Batal @@ -152,7 +154,7 @@ export function EditParticipant(props: IProps) { @@ -164,12 +166,86 @@ export function EditParticipant(props: IProps) { } export function DeleteParticipant(props: IProps) { + const apiUtils = api.useUtils(); + + const participantDeleteMutation = + api.participant.deleteParticipant.useMutation({ + onSuccess() { + toast.success("Operasi penghapusan berhasil!", { + description: "Berhasil menghapus pemilih tetap.", + }); + + props.openSetter(false); + }, + + onError(result) { + toast.error("Gagal menghapus peserta, coba lagi nanti.", { + description: result.message, + }); + }, + + async onSettled() { + await apiUtils.participant.getAllParticipants.invalidate(); + }, + }); + + const [confirmationText, setConfirmText] = useState(""); + + const reallySure = useMemo( + () => confirmationText === "saya ingin menghapus peserta ini", + [confirmationText], + ); + return ( props.openSetter((prev) => !prev)} title="Apakah anda yakin?" description={`Aksi yang anda lakukan dapat berakibat fatal. Jika anda melakukan hal ini, maka akan secara permanen menghapus data peserta bernama ${props.name}.`} - > + > + + Sebelum menghapus, ketik saya ingin menghapus peserta ini pada + kolom dibawah: + + { + e.preventDefault(); + + if (reallySure) + participantDeleteMutation.mutate({ qrId: props.qrId }); + }} + className="mt-3 space-y-3" + > + setConfirmText(e.target.value)} + /> + +
+ + + + + +
+ +
); } diff --git a/packages/api/src/router/participant.ts b/packages/api/src/router/participant.ts index c51871a9..a5edd81d 100644 --- a/packages/api/src/router/participant.ts +++ b/packages/api/src/router/participant.ts @@ -88,6 +88,45 @@ export const participantRouter = { }), ), + deleteParticipant: protectedProcedure + .input(participant.ServerDeleteParticipant) + .mutation(({ ctx, input }) => + ctx.db.transaction(async (tx) => { + // if (canAttendNow()) + // throw new TRPCError({ + // code: "UNAUTHORIZED", + // message: + // "Tidak di izinkan untuk menghapus peserta karena masih dalam masa diperbolehkan absen!", + // }); + + const participant = await tx.query.participants.findFirst({ + where: eq(schema.participants.qrId, input.qrId), + }); + + if (!participant) + throw new TRPCError({ + code: "NOT_FOUND", + message: "Peserta pemilihan tidak dapat ditemukan!", + }); + + if (participant.alreadyAttended) + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Peserta pemilihan sebelumnya sudah absen!", + }); + + if (participant.alreadyChoosing) + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Peserta pemilihan sebelumnya sudah memilih!", + }); + + return await tx + .delete(schema.participants) + .where(eq(schema.participants.qrId, input.qrId)); + }), + ), + exportJsonData: protectedProcedure.mutation(async () => { const participants = await preparedGetExcelParticipants.execute(); diff --git a/packages/validators/src/participant.ts b/packages/validators/src/participant.ts index bc2f8313..bb1ff4d9 100644 --- a/packages/validators/src/participant.ts +++ b/packages/validators/src/participant.ts @@ -35,10 +35,10 @@ const UploadParticipantSchema = z.object({ ), }); -const DeleteParticipantSchema = z.object({ id: z.number() }); - const ParticipantAttendSchema = z.string().refine(validateId); +const ServerDeleteParticipant = z.object({ qrId: ParticipantAttendSchema }); + const ServerUpdateParticipant = z.object({ name: baseNameSchema, subpart: baseSubpartSchema, @@ -49,7 +49,7 @@ export const participant = { SharedAddParticipant, SharedUploadManyParticipant, UploadParticipantSchema, - DeleteParticipantSchema, + ServerDeleteParticipant, ParticipantAttendSchema, ServerUpdateParticipant, } as const; From 3cda5b2136897cb7fa4bef37a50d40c38d568b75 Mon Sep 17 00:00:00 2001 From: Ezra Khairan Permana Date: Thu, 13 Jun 2024 00:03:28 +0700 Subject: [PATCH 32/44] style: menambahkan warna pada icon --- apps/web/src/app/_components/participant/export.tsx | 4 ++-- apps/web/src/app/_components/participant/new-participant.tsx | 4 ++-- apps/web/src/app/_components/participant/sudden-qr.tsx | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/src/app/_components/participant/export.tsx b/apps/web/src/app/_components/participant/export.tsx index 3f2ced3d..bd5fdbe0 100644 --- a/apps/web/src/app/_components/participant/export.tsx +++ b/apps/web/src/app/_components/participant/export.tsx @@ -34,7 +34,7 @@ export function ExportJSON() { return ( ); } @@ -206,7 +206,7 @@ export function ExportXLSX() { disabled={exportXlsx.isPending || additionalDisabled} > Ekspor XLSX - + ); } diff --git a/apps/web/src/app/_components/participant/new-participant.tsx b/apps/web/src/app/_components/participant/new-participant.tsx index 8cfc486d..427f37c5 100644 --- a/apps/web/src/app/_components/participant/new-participant.tsx +++ b/apps/web/src/app/_components/participant/new-participant.tsx @@ -112,7 +112,7 @@ export function SingleNewParticipant() { dialogTrigger={ } > @@ -258,7 +258,7 @@ export function UploadNewParticipant(params: type) { dialogTrigger={ } > diff --git a/apps/web/src/app/_components/participant/sudden-qr.tsx b/apps/web/src/app/_components/participant/sudden-qr.tsx index 77d6cf19..eaac93a9 100644 --- a/apps/web/src/app/_components/participant/sudden-qr.tsx +++ b/apps/web/src/app/_components/participant/sudden-qr.tsx @@ -84,7 +84,7 @@ export function SuddenQr() { From 93619ee2d5edf38fcbb8e9faa181df7d7e3bbf80 Mon Sep 17 00:00:00 2001 From: Ezra Khairan Permana Date: Fri, 14 Jun 2024 13:18:23 +0700 Subject: [PATCH 33/44] feat: tambah kandidat --- .gitignore | 3 + apps/web/package.json | 3 + .../admin/(adminRole)/kandidat/page.tsx | 16 +- .../app/_components/candidate/data-table.tsx | 286 ++++++++++++++++++ .../_components/candidate/new-candidate.tsx | 176 +++++++++++ .../_components/participant/data-table.tsx | 11 +- .../src/app/api/uploads/[filename]/route.ts | 23 ++ package.json | 6 +- packages/api/package.json | 2 + packages/api/src/root.ts | 4 +- packages/api/src/router/candidate.ts | 54 ++++ packages/api/src/router/participant.ts | 13 +- packages/db/src/index.ts | 4 + packages/id-generator/generator.ts | 1 + packages/settings/src/index.ts | 33 +- packages/validators/package.json | 1 + packages/validators/src/candidate.ts | 42 +++ packages/validators/src/index.ts | 1 + turbo.json | 53 ++-- yarn.lock | 96 ++++-- 20 files changed, 762 insertions(+), 66 deletions(-) create mode 100644 apps/web/src/app/_components/candidate/data-table.tsx create mode 100644 apps/web/src/app/_components/candidate/new-candidate.tsx create mode 100644 apps/web/src/app/api/uploads/[filename]/route.ts create mode 100644 packages/api/src/router/candidate.ts create mode 100644 packages/validators/src/candidate.ts diff --git a/.gitignore b/.gitignore index 2b39dec4..d7d82d9a 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,6 @@ dist/ # turbo .turbo + +# web app +apps/web/public/uploads diff --git a/apps/web/package.json b/apps/web/package.json index bc0816ae..a263a52a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -29,7 +29,9 @@ "date-fns": "^3.6.0", "exceljs": "^4.4.0", "geist": "^1.3.0", + "js-base64": "^3.7.7", "lucide-react": "^0.390.0", + "mime-types": "^2.1.35", "next": "^14.2.3", "qrcode": "^1.5.3", "react": "18.3.1", @@ -42,6 +44,7 @@ "@sora-vp/prettier-config": "*", "@sora-vp/tailwind-config": "*", "@sora-vp/tsconfig": "*", + "@types/mime-types": "^2", "@types/node": "^20.12.9", "@types/qrcode": "^1", "@types/react": "^18.3.1", diff --git a/apps/web/src/app/(auth)/admin/(adminRole)/kandidat/page.tsx b/apps/web/src/app/(auth)/admin/(adminRole)/kandidat/page.tsx index 83291b32..afdc5a24 100644 --- a/apps/web/src/app/(auth)/admin/(adminRole)/kandidat/page.tsx +++ b/apps/web/src/app/(auth)/admin/(adminRole)/kandidat/page.tsx @@ -1,3 +1,17 @@ +import { DataTable } from "~/app/_components/candidate/data-table"; + export default function CandidatePage() { - return <>; + return ( +
+
+

Kandidat

+

+ Kelola siapa saja yang menjadi kandidat untuk dipilih pada halaman + ini. +

+
+ + +
+ ); } diff --git a/apps/web/src/app/_components/candidate/data-table.tsx b/apps/web/src/app/_components/candidate/data-table.tsx new file mode 100644 index 00000000..1c2e74b8 --- /dev/null +++ b/apps/web/src/app/_components/candidate/data-table.tsx @@ -0,0 +1,286 @@ +"use client"; + +import type { RouterOutputs } from "@sora-vp/api"; +import type { + ColumnDef, + ColumnFiltersState, + SortingState, + VisibilityState, +} from "@tanstack/react-table"; +import { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react"; +import { + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { ChevronDown, MoreHorizontal, PencilLine, Trash2 } from "lucide-react"; + +import { Button } from "@sora-vp/ui/button"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@sora-vp/ui/dropdown-menu"; +import { Skeleton } from "@sora-vp/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@sora-vp/ui/table"; +import { toast } from "@sora-vp/ui/toast"; + +import { api } from "~/trpc/react"; +import { NewCandidate } from "./new-candidate"; + +type CandidateList = RouterOutputs["candidate"]["candidateList"][number]; + +export const GlobalSystemAllowance = createContext(true); + +const columns: ColumnDef[] = [ + { + accessorKey: "name", + header: "Nama Kandidat", + }, + { + accessorKey: "counter", + header: "Jumlah Pemilih", + cell: ({ row }) => {row.getValue("counter")} Orang, + }, + { + accessorKey: "image", + header: "Gambar Kandidat", + cell: ({ row }) => ( + + ), + }, + { + id: "actions", + enableHiding: false, + cell: ({ row }) => { + const candidate = row.original; + + // eslint-disable-next-line react-hooks/rules-of-hooks + const [openEdit, setOpenEdit] = useState(false); + + // eslint-disable-next-line react-hooks/rules-of-hooks + const [openDelete, setOpenDelete] = useState(false); + + // eslint-disable-next-line react-hooks/rules-of-hooks + const globallyAllowedToOpen = useContext(GlobalSystemAllowance); + + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + if (!globallyAllowedToOpen) { + setOpenEdit(false); + setOpenDelete(false); + } + }, [globallyAllowedToOpen]); + + return ( + <> + + + + + + Aksi + setOpenEdit(true)} + > + + Ubah Identitas + + setOpenDelete(true)} + // disabled={participant.alreadyAttended} + > + + Hapus Kandidat + + + + + ); + }, + }, +]; + +export function DataTable() { + const candidateQuery = api.candidate.candidateQuery.useQuery(); + + const settingsQuery = api.settings.getSettings.useQuery(undefined, { + refetchInterval: 3500, + refetchIntervalInBackground: true, + }); + + const [columnVisibility, setColumnVisibility] = useState({}); + + const [allowedToOpenModifier, setAllowedOpen] = useState(true); + + const table = useReactTable({ + data: candidateQuery.data ?? [], + columns, + getCoreRowModel: getCoreRowModel(), + onColumnVisibilityChange: setColumnVisibility, + state: { + columnVisibility, + }, + }); + + useEffect(() => { + if (settingsQuery.data) { + setAllowedOpen(!settingsQuery.data.canVote); + } + }, [settingsQuery.data]); + + return ( + +
+
+ + + + + + + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + > + {column.id} + + ); + })} + + +
+ +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} + + ))} + + + {candidateQuery.isError ? ( + + + Error: {candidateQuery.error.message} + + + ) : null} + + {candidateQuery.isLoading && !candidateQuery.isError ? ( + <> + {Array.from({ length: 10 }).map((_, idx) => ( + + + + + + ))} + + ) : null} + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + <> + {!candidateQuery.isLoading && ( + <> + {!candidateQuery.isError && ( + + + Tidak ada data. + + + )} + + )} + + )} + +
+
+ + {/* for scrollable purpose */} +
+
+ + ); +} diff --git a/apps/web/src/app/_components/candidate/new-candidate.tsx b/apps/web/src/app/_components/candidate/new-candidate.tsx new file mode 100644 index 00000000..e7af57c1 --- /dev/null +++ b/apps/web/src/app/_components/candidate/new-candidate.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { useCallback, useContext, useEffect, useState } from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { UserPlus } from "lucide-react"; +import { useForm } from "react-hook-form"; + +import { Button } from "@sora-vp/ui/button"; +import { DialogClose } from "@sora-vp/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@sora-vp/ui/form"; +import { Input } from "@sora-vp/ui/input"; +import { toast } from "@sora-vp/ui/toast"; +import { candidate } from "@sora-vp/validators"; + +import { ReusableDialog } from "~/app/_components/participant/new-participant"; +import { api } from "~/trpc/react"; +import { GlobalSystemAllowance } from "./data-table"; + +type FormSchema = { + name: string; + image: File; +}; + +const toBase64 = (file: File) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => { + const splitted = reader.result!.split(","); + resolve(splitted.at(1) as string); + }; + reader.onerror = reject; + }); + +export function NewCandidate() { + const [isOpen, setDialogOpen] = useState(false); + + const apiUtils = api.useUtils(); + + const form = useForm({ + resolver: zodResolver(candidate.AddNewCandidateSchema), + defaultValues: { + name: "", + }, + }); + + const candidateMutation = api.candidate.createNewCandidate.useMutation({ + onSuccess() { + toast.success("Operasi penambahan berhasil!", { + description: "Berhasil menambahkan kandidat baru.", + }); + + setDialogOpen(false); + form.reset(); + }, + + onError(result) { + toast.error("Gagal menambahkan kandidat, coba lagi nanti.", { + description: result.message, + }); + }, + + async onSettled() { + await apiUtils.candidate.candidateQuery.invalidate(); + }, + }); + + const globallyAllowedToOpen = useContext(GlobalSystemAllowance); + + const setOpenCb = useCallback(() => { + if (!candidateMutation.isPending) setDialogOpen((prev) => !prev); + }, [candidateMutation.isPending]); + + async function onSubmit(values: FormSchema) { + const file = values.image.item(0)!; + const image = await toBase64(file); + + candidateMutation.mutate({ + name: values.name, + image, + type: file.type, + }); + } + + useEffect(() => { + if (!globallyAllowedToOpen) setDialogOpen(false); + }, [globallyAllowedToOpen]); + + return ( + + Tambah Kandidat Baru + + + } + > +
+ + ( + + Nama Kandidat + + + + + Nama kandidat atau paslon. Jika kandidat adalah pasangan + calon, gunakan tanda hubung (-) supaya pemilih memahami dengan + jelas. + + + + )} + /> + ( + + Gambar Kandidat + + + + + Gambar kandidat yang akan dilihat oleh pemilih. + + + + )} + /> + +
+ + + + + +
+ + +
+ ); +} diff --git a/apps/web/src/app/_components/participant/data-table.tsx b/apps/web/src/app/_components/participant/data-table.tsx index f4e05ae4..19890628 100644 --- a/apps/web/src/app/_components/participant/data-table.tsx +++ b/apps/web/src/app/_components/participant/data-table.tsx @@ -1,5 +1,6 @@ "use client"; +import type { RouterOutputs } from "@sora-vp/api"; import type { ColumnDef, ColumnFiltersState, @@ -79,6 +80,9 @@ import { SingleNewParticipant, UploadNewParticipant } from "./new-participant"; import { DeleteParticipant, EditParticipant } from "./participant-action"; import { SuddenQr } from "./sudden-qr"; +type ParticipantList = + RouterOutputs["participant"]["getAllParticipants"][number]; + const GlobalSystemAllowance = createContext(true); const MonoFont = Space_Mono({ @@ -86,7 +90,7 @@ const MonoFont = Space_Mono({ subsets: ["latin"], }); -const columns: ColumnDef[] = [ +const columns: ColumnDef[] = [ { id: "participantName", accessorKey: "name", @@ -292,14 +296,13 @@ export function DataTable() { ); const settingsQuery = api.settings.getSettings.useQuery(undefined, { - refetchInterval: 5500, + refetchInterval: 3500, refetchIntervalInBackground: true, }); const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); const [columnVisibility, setColumnVisibility] = useState({}); - const [rowSelection, setRowSelection] = useState({}); const [allowedToOpenModifier, setAllowedOpen] = useState(true); @@ -313,13 +316,11 @@ export function DataTable() { getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), onColumnVisibilityChange: setColumnVisibility, - onRowSelectionChange: setRowSelection, initialState: { pagination: { pageSize: 20 } }, state: { sorting, columnFilters, columnVisibility, - rowSelection, }, }); diff --git a/apps/web/src/app/api/uploads/[filename]/route.ts b/apps/web/src/app/api/uploads/[filename]/route.ts new file mode 100644 index 00000000..8bfe5c67 --- /dev/null +++ b/apps/web/src/app/api/uploads/[filename]/route.ts @@ -0,0 +1,23 @@ +import fs from "fs"; +import path from "path"; +import mime from "mime-types"; + +const ROOT_PATH = path.join(path.resolve(), "public/uploads"); + +export function GET( + request: Request, + { params }: { params: { filename: string } }, +) { + const filename = params.filename; + + const safeSuffix = path.normalize(filename).replace(/^(\.\.(\/|\\|$))+/, ""); + const filePath = path.join(ROOT_PATH, safeSuffix); + + if (!fs.existsSync(filePath)) + return Response.json({ message: "Image not found!" }, { status: 404 }); + + const image = fs.readFileSync(filePath); + const contentType = mime.lookup(filePath); + + return new Response(image, { headers: { "content-type": contentType } }); +} diff --git a/package.json b/package.json index 4b9670af..e0f54a7b 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,8 @@ "db:push": "yarn workspace @sora-vp/db push", "db:studio": "yarn workspace @sora-vp/db studio", "dev": "turbo dev --parallel", - "dev:web": "turbo run dev --scope=@sora-vp/web", - "dev:processor": "turbo run dev --scope=@sora-vp/processor", + "dev:web": "turbo run dev --filter @sora-vp/web", + "dev:processor": "turbo run dev --filter @sora-vp/processor", "format": "turbo format --continue -- --cache --cache-location node_modules/.cache/.prettiercache", "format:fix": "turbo format --continue -- --write --cache --cache-location node_modules/.cache/.prettiercache", "lint": "turbo lint --continue -- --cache --cache-location node_modules/.cache/.eslintcache", @@ -26,7 +26,7 @@ "@sora-vp/prettier-config": "*", "@turbo/gen": "^1.13.3", "prettier": "^3.2.5", - "turbo": "^1.13.3", + "turbo": "^2.0.3", "typescript": "^5.4.5" }, "prettier": "@sora-vp/prettier-config", diff --git a/packages/api/package.json b/packages/api/package.json index 1adea769..564c58fe 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -25,6 +25,7 @@ "@sora-vp/validators": "*", "@trpc/server": "11.0.0-rc.364", "bcrypt": "^5.1.1", + "mime-types": "^2.1.35", "superjson": "2.2.1", "zod": "^3.23.6" }, @@ -33,6 +34,7 @@ "@sora-vp/prettier-config": "*", "@sora-vp/tsconfig": "*", "@types/bcrypt": "^5", + "@types/mime-types": "^2", "eslint": "^9.2.0", "prettier": "^3.2.5", "typescript": "^5.4.5" diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index fa528cd3..6bde16fe 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -1,13 +1,15 @@ import { adminRouter } from "./router/admin"; import { authRouter } from "./router/auth"; +import { candidateRouter } from "./router/candidate"; import { participantRouter } from "./router/participant"; import { settingsRouter } from "./router/settings"; import { createTRPCRouter } from "./trpc"; export const appRouter = createTRPCRouter({ auth: authRouter, - settings: settingsRouter, admin: adminRouter, + candidate: candidateRouter, + settings: settingsRouter, participant: participantRouter, }); diff --git a/packages/api/src/router/candidate.ts b/packages/api/src/router/candidate.ts new file mode 100644 index 00000000..fa57d3de --- /dev/null +++ b/packages/api/src/router/candidate.ts @@ -0,0 +1,54 @@ +import { Buffer } from "buffer"; +import { existsSync, mkdirSync } from "fs"; +import { writeFile } from "fs/promises"; +import path from "path"; +import type { TRPCRouterRecord } from "@trpc/server"; +import { TRPCError } from "@trpc/server"; +import mime from "mime-types"; + +import { + // eq, + preparedAdminGetCandidates, + // preparedGetExcelParticipants, + schema, +} from "@sora-vp/db"; +import { randomFileName } from "@sora-vp/id-generator"; +import { canVoteNow } from "@sora-vp/settings"; +import { candidate } from "@sora-vp/validators"; + +import { adminProcedure } from "../trpc"; + +// Don't worry, it automatically goes to the web app public directory +const NEXT_ROOT_PATH = path.join(path.resolve(), "public/uploads"); + +export const candidateRouter = { + candidateQuery: adminProcedure.query(() => + preparedAdminGetCandidates.execute(), + ), + + createNewCandidate: adminProcedure + .input(candidate.ServerAddNewCandidate) + .mutation(({ ctx, input }) => + ctx.db.transaction(async (tx) => { + if (canVoteNow()) + throw new TRPCError({ + code: "UNAUTHORIZED", + message: + "Tidak bisa menambahkan kandidat baru pada saat kondisi pemilihan!", + }); + + const fileName = `${randomFileName()}.${mime.extension(input.type)}`; + + if (!existsSync(NEXT_ROOT_PATH)) mkdirSync(NEXT_ROOT_PATH); + + const imageContent = Buffer.from(input.image, "base64"); + + await writeFile(path.join(NEXT_ROOT_PATH, fileName), imageContent); + + return await tx.insert(schema.candidates).values({ + name: input.name, + image: fileName, + }); + }), + ), +} as TRPCRouterRecord; diff --git a/packages/api/src/router/participant.ts b/packages/api/src/router/participant.ts index a5edd81d..b75309c5 100644 --- a/packages/api/src/router/participant.ts +++ b/packages/api/src/router/participant.ts @@ -7,6 +7,7 @@ import { preparedGetExcelParticipants, schema, } from "@sora-vp/db"; +import { canAttendNow } from "@sora-vp/settings"; import { participant } from "@sora-vp/validators"; import { protectedProcedure } from "../trpc"; @@ -92,12 +93,12 @@ export const participantRouter = { .input(participant.ServerDeleteParticipant) .mutation(({ ctx, input }) => ctx.db.transaction(async (tx) => { - // if (canAttendNow()) - // throw new TRPCError({ - // code: "UNAUTHORIZED", - // message: - // "Tidak di izinkan untuk menghapus peserta karena masih dalam masa diperbolehkan absen!", - // }); + if (canAttendNow()) + throw new TRPCError({ + code: "UNAUTHORIZED", + message: + "Tidak di izinkan untuk menghapus peserta karena masih dalam masa diperbolehkan absen!", + }); const participant = await tx.query.participants.findFirst({ where: eq(schema.participants.qrId, input.qrId), diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index b6d80749..b57324cb 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -46,3 +46,7 @@ export const preparedGetExcelParticipants = db.query.participants }, }) .prepare(); + +export const preparedAdminGetCandidates = db.query.candidates + .findMany() + .prepare(); diff --git a/packages/id-generator/generator.ts b/packages/id-generator/generator.ts index 7870dfe5..3988f96b 100644 --- a/packages/id-generator/generator.ts +++ b/packages/id-generator/generator.ts @@ -4,6 +4,7 @@ const customToken = "1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ"; const arrayValidator = customToken.split(""); export const nanoid = customAlphabet(customToken, 15); +export const randomFileName = customAlphabet('1234567890abcdef', 10); export const validateId = (id: string) => id diff --git a/packages/settings/src/index.ts b/packages/settings/src/index.ts index c18795e3..6ae6336e 100644 --- a/packages/settings/src/index.ts +++ b/packages/settings/src/index.ts @@ -1,3 +1,34 @@ import { settings } from "./SettingsManager"; -export default settings; +export { settings as default } from "./SettingsManager"; + +const getTimePermission = () => { + const currentSettings = settings.getSettings(); + const currentTime = new Date().getTime(); + + const currentTimeIsBiggerThanStart = + currentSettings.startTime && + currentTime >= currentSettings.startTime.getTime(); + + const currentTimeIsSmallerThanEnd = + currentSettings.startTime && + currentTime <= currentSettings.endTime.getTime(); + + return { + isPermittedByTime: + currentTimeIsBiggerThanStart && currentTimeIsSmallerThanEnd, + settings: currentSettings, + }; +}; + +export const canVoteNow = () => { + const { isPermittedByTime, settings } = getTimePermission(); + + return isPermittedByTime && settings.canVote; +}; + +export const canAttendNow = () => { + const { isPermittedByTime, settings } = getTimePermission(); + + return isPermittedByTime && settings.canAttend; +}; diff --git a/packages/validators/package.json b/packages/validators/package.json index 7f529b10..a03cf95b 100644 --- a/packages/validators/package.json +++ b/packages/validators/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "@sora-vp/id-generator": "*", + "js-base64": "^3.7.7", "zod": "^3.23.6" }, "devDependencies": { diff --git a/packages/validators/src/candidate.ts b/packages/validators/src/candidate.ts new file mode 100644 index 00000000..bc3e61f8 --- /dev/null +++ b/packages/validators/src/candidate.ts @@ -0,0 +1,42 @@ +import { Base64 } from "js-base64"; +import { z } from "zod"; + +const TwoMegs = 2_000_000; +const ACCEPTED_IMAGE_TYPES = [ + "image/jpeg", + "image/jpg", + "image/png", + "image/webp", +]; + +const baseAddAndEditForm = z.object({ + name: z.string().min(1, { message: "Diperlukan nama kandidat!" }), +}); + +const AddNewCandidateSchema = baseAddAndEditForm.merge( + z.object({ + image: z + .any() + .refine((files) => files?.length == 1, "Diperlukan gambar kandidat!") + .refine( + (files) => files?.[0]?.size <= TwoMegs, + `Ukuran maksimal gambar adalah 2MB!`, + ) + .refine( + (files) => ACCEPTED_IMAGE_TYPES.includes(files?.[0]?.type), + "Hanya format gambar .jpg, .jpeg, .png dan .webp yang diterima!", + ), + }), +); + +const ServerAddNewCandidate = baseAddAndEditForm.merge( + z.object({ + image: z.string().refine(Base64.isValid), + type: z.string(), + }), +); + +export const candidate = { + AddNewCandidateSchema, + ServerAddNewCandidate, +} as const; diff --git a/packages/validators/src/index.ts b/packages/validators/src/index.ts index e4c069ef..07aabae2 100644 --- a/packages/validators/src/index.ts +++ b/packages/validators/src/index.ts @@ -2,3 +2,4 @@ export * from "./auth"; export * from "./admin"; export * from "./settings"; export * from "./participant"; +export * from "./candidate"; diff --git a/turbo.json b/turbo.json index 224f45c8..9d83a5de 100644 --- a/turbo.json +++ b/turbo.json @@ -1,13 +1,25 @@ { "$schema": "https://turborepo.org/schema.json", - "experimentalUI": true, - "globalDependencies": ["**/.env"], - "pipeline": { + "globalDependencies": [ + "**/.env" + ], + "globalEnv": [ + "DATABASE_URL", + "AUTH_DISCORD_ID", + "AUTH_DISCORD_SECRET", + "AUTH_REDIRECT_PROXY_URL", + "AUTH_SECRET" + ], + "tasks": { "topo": { - "dependsOn": ["^topo"] + "dependsOn": [ + "^topo" + ] }, "build": { - "dependsOn": ["^build"], + "dependsOn": [ + "^build" + ], "outputs": [ ".next/**", "!.next/cache/**", @@ -22,16 +34,26 @@ "cache": false }, "format": { - "outputs": ["node_modules/.cache/.prettiercache"], - "outputMode": "new-only" + "outputs": [ + "node_modules/.cache/.prettiercache" + ], + "outputLogs": "new-only" }, "lint": { - "dependsOn": ["^topo"], - "outputs": ["node_modules/.cache/.eslintcache"] + "dependsOn": [ + "^topo" + ], + "outputs": [ + "node_modules/.cache/.eslintcache" + ] }, "typecheck": { - "dependsOn": ["^topo"], - "outputs": ["node_modules/.cache/tsbuildinfo.json"] + "dependsOn": [ + "^topo" + ], + "outputs": [ + "node_modules/.cache/tsbuildinfo.json" + ] }, "clean": { "cache": false @@ -39,12 +61,5 @@ "//#clean": { "cache": false } - }, - "globalEnv": [ - "DATABASE_URL", - "AUTH_DISCORD_ID", - "AUTH_DISCORD_SECRET", - "AUTH_REDIRECT_PROXY_URL", - "AUTH_SECRET" - ] + } } diff --git a/yarn.lock b/yarn.lock index 8bd7a388..2647fa92 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2653,8 +2653,10 @@ __metadata: "@sora-vp/validators": "npm:*" "@trpc/server": "npm:11.0.0-rc.364" "@types/bcrypt": "npm:^5" + "@types/mime-types": "npm:^2" bcrypt: "npm:^5.1.1" eslint: "npm:^9.2.0" + mime-types: "npm:^2.1.35" prettier: "npm:^3.2.5" superjson: "npm:2.2.1" typescript: "npm:^5.4.5" @@ -2886,6 +2888,7 @@ __metadata: "@sora-vp/prettier-config": "npm:*" "@sora-vp/tsconfig": "npm:*" eslint: "npm:^9.2.0" + js-base64: "npm:^3.7.7" prettier: "npm:^3.2.5" typescript: "npm:^5.4.5" zod: "npm:^3.23.6" @@ -2911,6 +2914,7 @@ __metadata: "@trpc/client": "npm:11.0.0-rc.364" "@trpc/react-query": "npm:11.0.0-rc.364" "@trpc/server": "npm:11.0.0-rc.364" + "@types/mime-types": "npm:^2" "@types/node": "npm:^20.12.9" "@types/qrcode": "npm:^1" "@types/react": "npm:^18.3.1" @@ -2922,7 +2926,9 @@ __metadata: exceljs: "npm:^4.4.0" geist: "npm:^1.3.0" jiti: "npm:^1.21.0" + js-base64: "npm:^3.7.7" lucide-react: "npm:^0.390.0" + mime-types: "npm:^2.1.35" next: "npm:^14.2.3" prettier: "npm:^3.2.5" qrcode: "npm:^1.5.3" @@ -3215,6 +3221,13 @@ __metadata: languageName: node linkType: hard +"@types/mime-types@npm:^2": + version: 2.1.4 + resolution: "@types/mime-types@npm:2.1.4" + checksum: 10c0/a10d57881d14a053556b3d09292de467968d965b0a06d06732c748da39b3aa569270b5b9f32529fd0e9ac1e5f3b91abb894f5b1996373254a65cb87903c86622 + languageName: node + linkType: hard + "@types/minimatch@npm:*": version: 5.1.2 resolution: "@types/minimatch@npm:5.1.2" @@ -8122,6 +8135,13 @@ __metadata: languageName: node linkType: hard +"js-base64@npm:^3.7.7": + version: 3.7.7 + resolution: "js-base64@npm:3.7.7" + checksum: 10c0/3c905a7e78b601e4751b5e710edd0d6d045ce2d23eb84c9df03515371e1b291edc72808dc91e081cb9855aef6758292a2407006f4608ec3705373dd8baf2f80f + languageName: node + linkType: hard + "js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" @@ -8725,6 +8745,22 @@ __metadata: languageName: node linkType: hard +"mime-db@npm:1.52.0": + version: 1.52.0 + resolution: "mime-db@npm:1.52.0" + checksum: 10c0/0557a01deebf45ac5f5777fe7740b2a5c309c6d62d40ceab4e23da9f821899ce7a900b7ac8157d4548ddbb7beffe9abc621250e6d182b0397ec7f10c7b91a5aa + languageName: node + linkType: hard + +"mime-types@npm:^2.1.35": + version: 2.1.35 + resolution: "mime-types@npm:2.1.35" + dependencies: + mime-db: "npm:1.52.0" + checksum: 10c0/82fb07ec56d8ff1fc999a84f2f217aa46cb6ed1033fefaabd5785b9a974ed225c90dc72fff460259e66b95b73648596dbcc50d51ed69cdf464af2d237d3149b2 + languageName: node + linkType: hard + "mime@npm:1.6.0": version: 1.6.0 resolution: "mime@npm:1.6.0" @@ -11254,7 +11290,7 @@ __metadata: "@sora-vp/prettier-config": "npm:*" "@turbo/gen": "npm:^1.13.3" prettier: "npm:^3.2.5" - turbo: "npm:^1.13.3" + turbo: "npm:^2.0.3" typescript: "npm:^5.4.5" languageName: unknown linkType: soft @@ -12038,58 +12074,58 @@ __metadata: languageName: node linkType: hard -"turbo-darwin-64@npm:1.13.3": - version: 1.13.3 - resolution: "turbo-darwin-64@npm:1.13.3" +"turbo-darwin-64@npm:2.0.3": + version: 2.0.3 + resolution: "turbo-darwin-64@npm:2.0.3" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"turbo-darwin-arm64@npm:1.13.3": - version: 1.13.3 - resolution: "turbo-darwin-arm64@npm:1.13.3" +"turbo-darwin-arm64@npm:2.0.3": + version: 2.0.3 + resolution: "turbo-darwin-arm64@npm:2.0.3" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"turbo-linux-64@npm:1.13.3": - version: 1.13.3 - resolution: "turbo-linux-64@npm:1.13.3" +"turbo-linux-64@npm:2.0.3": + version: 2.0.3 + resolution: "turbo-linux-64@npm:2.0.3" conditions: os=linux & cpu=x64 languageName: node linkType: hard -"turbo-linux-arm64@npm:1.13.3": - version: 1.13.3 - resolution: "turbo-linux-arm64@npm:1.13.3" +"turbo-linux-arm64@npm:2.0.3": + version: 2.0.3 + resolution: "turbo-linux-arm64@npm:2.0.3" conditions: os=linux & cpu=arm64 languageName: node linkType: hard -"turbo-windows-64@npm:1.13.3": - version: 1.13.3 - resolution: "turbo-windows-64@npm:1.13.3" +"turbo-windows-64@npm:2.0.3": + version: 2.0.3 + resolution: "turbo-windows-64@npm:2.0.3" conditions: os=win32 & cpu=x64 languageName: node linkType: hard -"turbo-windows-arm64@npm:1.13.3": - version: 1.13.3 - resolution: "turbo-windows-arm64@npm:1.13.3" +"turbo-windows-arm64@npm:2.0.3": + version: 2.0.3 + resolution: "turbo-windows-arm64@npm:2.0.3" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"turbo@npm:^1.13.3": - version: 1.13.3 - resolution: "turbo@npm:1.13.3" - dependencies: - turbo-darwin-64: "npm:1.13.3" - turbo-darwin-arm64: "npm:1.13.3" - turbo-linux-64: "npm:1.13.3" - turbo-linux-arm64: "npm:1.13.3" - turbo-windows-64: "npm:1.13.3" - turbo-windows-arm64: "npm:1.13.3" +"turbo@npm:^2.0.3": + version: 2.0.3 + resolution: "turbo@npm:2.0.3" + dependencies: + turbo-darwin-64: "npm:2.0.3" + turbo-darwin-arm64: "npm:2.0.3" + turbo-linux-64: "npm:2.0.3" + turbo-linux-arm64: "npm:2.0.3" + turbo-windows-64: "npm:2.0.3" + turbo-windows-arm64: "npm:2.0.3" dependenciesMeta: turbo-darwin-64: optional: true @@ -12105,7 +12141,7 @@ __metadata: optional: true bin: turbo: bin/turbo - checksum: 10c0/0382cc88f65a6690e97d30a6ad5d9b9ede7705f5f57edea27629408b166eff4a5938824746ce2cbcd50d2b64ebdbd359160e2cbe009f0bfd6f144997f9f6705b + checksum: 10c0/154f2fab8ce2faa66444a32950171e3cb520576d07f20c58f13052442997a80bf31146e5826ce4e05abddfe99d71c68f309b7efab84cad05ee984352ef1f2621 languageName: node linkType: hard From 5a860f756834ad3e3eaf3ed3932103cd8afb2a3d Mon Sep 17 00:00:00 2001 From: Ezra Khairan Permana Date: Fri, 14 Jun 2024 13:47:07 +0700 Subject: [PATCH 34/44] feat: tambah kandidat --- .../candidate/candidate-action.tsx | 251 ++++++++++++++++++ .../app/_components/candidate/data-table.tsx | 29 +- .../_components/candidate/new-candidate.tsx | 4 +- 3 files changed, 273 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/app/_components/candidate/candidate-action.tsx diff --git a/apps/web/src/app/_components/candidate/candidate-action.tsx b/apps/web/src/app/_components/candidate/candidate-action.tsx new file mode 100644 index 00000000..e3f4675c --- /dev/null +++ b/apps/web/src/app/_components/candidate/candidate-action.tsx @@ -0,0 +1,251 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { FileText, UserPlus } from "lucide-react"; +import { useForm, useWatch } from "react-hook-form"; +import { z } from "zod"; + +import { Button } from "@sora-vp/ui/button"; +import { DialogClose, DialogDescription } from "@sora-vp/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@sora-vp/ui/form"; +import { Input } from "@sora-vp/ui/input"; +import { toast } from "@sora-vp/ui/toast"; +import { candidate } from "@sora-vp/validators"; + +import { ReusableDialog } from "~/app/_components/participant/new-participant"; +import { api } from "~/trpc/react"; +import { toBase64 } from "./new-candidate"; + +interface IProps { + dialogOpen: boolean; + openSetter: React.Dispatch>; + name: string; + id: string; +} + +type EditFormSchema = z.infer; + +export function EditCandidate(props: IProps) { + const apiUtils = api.useUtils(); + + const form = useForm({ + // resolver: zodResolver(candidate), + defaultValues: { + name: props.name, + }, + }); + + const candidateEditMutation = api.candidate.updateCandidate.useMutation({ + onSuccess() { + toast.success("Operasi pengubahan berhasil!", { + description: "Berhasil mengubah kanddidat.", + }); + + props.openSetter(false); + }, + + onError(result) { + toast.error("Gagal mengubah kandidat, coba lagi nanti.", { + description: result.message, + }); + }, + + async onSettled() { + await apiUtils.candidate.candidateQuery.invalidate(); + }, + }); + + const currentName = useWatch({ control: form.control, name: "name" }); + + const stillTheSameValue = useMemo( + () => currentName === props.name, + [currentName, props.name], + ); + + return ( + { + if (!candidateEditMutation.isPending) + props.openSetter((prev) => { + const newValue = !prev; + + if (!newValue) setTimeout(() => form.reset(), 1000); + + return newValue; + }); + }} + title="Perbaiki Identitas Kandidat" + description="Perbarui identitas kandidat yang mungkin salah dalam penulisan nama ataupun gambarnya." + > +
+ + participantEditMutation.mutate({ ...data, qrId: props.qrId }), + )} + className="space-y-5" + > +
+ ( + + Nama Kandidat + + + + + Nama kandidat atau paslon. Jika kandidat adalah pasangan + calon, gunakan tanda hubung (-) supaya pemilih memahami + dengan jelas. + + + + )} + /> + ( + + Gambar Kandidat + + + + + Gambar kandidat yang akan dilihat oleh pemilih. + + + + )} + /> +
+ +
+ + + + + +
+
+ +
+ ); +} + +export function DeleteParticipant(props: IProps) { + const apiUtils = api.useUtils(); + + const participantDeleteMutation = + api.participant.deleteParticipant.useMutation({ + onSuccess() { + toast.success("Operasi penghapusan berhasil!", { + description: "Berhasil menghapus pemilih tetap.", + }); + + props.openSetter(false); + }, + + onError(result) { + toast.error("Gagal menghapus peserta, coba lagi nanti.", { + description: result.message, + }); + }, + + async onSettled() { + await apiUtils.participant.getAllParticipants.invalidate(); + }, + }); + + const [confirmationText, setConfirmText] = useState(""); + + const reallySure = useMemo( + () => confirmationText === "saya ingin menghapus peserta ini", + [confirmationText], + ); + + return ( + props.openSetter((prev) => !prev)} + title="Apakah anda yakin?" + description={`Aksi yang anda lakukan dapat berakibat fatal. Jika anda melakukan hal ini, maka akan secara permanen menghapus data peserta bernama ${props.name}.`} + > + + Sebelum menghapus, ketik saya ingin menghapus peserta ini pada + kolom dibawah: + +
{ + e.preventDefault(); + + if (reallySure) + participantDeleteMutation.mutate({ qrId: props.qrId }); + }} + className="mt-3 space-y-3" + > + setConfirmText(e.target.value)} + /> + +
+ + + + + +
+
+
+ ); +} diff --git a/apps/web/src/app/_components/candidate/data-table.tsx b/apps/web/src/app/_components/candidate/data-table.tsx index 1c2e74b8..61bc7a9e 100644 --- a/apps/web/src/app/_components/candidate/data-table.tsx +++ b/apps/web/src/app/_components/candidate/data-table.tsx @@ -43,6 +43,7 @@ import { import { toast } from "@sora-vp/ui/toast"; import { api } from "~/trpc/react"; +import { EditCandidate } from "./candidate-action"; import { NewCandidate } from "./new-candidate"; type CandidateList = RouterOutputs["candidate"]["candidateList"][number]; @@ -57,7 +58,9 @@ const columns: ColumnDef[] = [ { accessorKey: "counter", header: "Jumlah Pemilih", - cell: ({ row }) => {row.getValue("counter")} Orang, + cell: ({ row }) => ( + {row.getValue("counter").toLocaleString("id-ID")} Orang + ), }, { accessorKey: "image", @@ -99,11 +102,7 @@ const columns: ColumnDef[] = [ @@ -258,7 +265,7 @@ export function DeleteParticipant(props: IProps) { diff --git a/apps/web/src/app/_components/candidate/data-table.tsx b/apps/web/src/app/_components/candidate/data-table.tsx index 61bc7a9e..4161b99c 100644 --- a/apps/web/src/app/_components/candidate/data-table.tsx +++ b/apps/web/src/app/_components/candidate/data-table.tsx @@ -43,7 +43,7 @@ import { import { toast } from "@sora-vp/ui/toast"; import { api } from "~/trpc/react"; -import { EditCandidate } from "./candidate-action"; +import { DeleteCandidate, EditCandidate } from "./candidate-action"; import { NewCandidate } from "./new-candidate"; type CandidateList = RouterOutputs["candidate"]["candidateList"][number]; @@ -136,6 +136,12 @@ const columns: ColumnDef[] = [ name={candidate.name} id={candidate.id} /> + ); }, diff --git a/packages/api/src/router/candidate.ts b/packages/api/src/router/candidate.ts index adf2c803..cdce3e9e 100644 --- a/packages/api/src/router/candidate.ts +++ b/packages/api/src/router/candidate.ts @@ -102,4 +102,41 @@ export const candidateRouter = { .where(eq(schema.candidates.id, input.id)); }), ), + + deleteCandidate: adminProcedure + .input(candidate.ServerDeleteCandidate) + .mutation(({ ctx, input }) => + ctx.db.transaction(async (tx) => { + if (canVoteNow()) + throw new TRPCError({ + code: "UNAUTHORIZED", + message: + "Tidak bisa menghapus kandidat baru pada saat kondisi pemilihan!", + }); + + const candidate = await tx.query.candidates.findFirst({ + where: eq(schema.candidates.id, input.id), + }); + + if (!candidate) + throw new TRPCError({ + code: "NOT_FOUND", + message: "Kandidat yang ingin di ubah tidak ditemukan!", + }); + + if (candidate.counter > 0) + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Tidak bisa menghapus kandidat karena sudah ada yang memilih!", + }); + + if (existsSync(path.join(NEXT_ROOT_PATH, candidate.image))) + await unlink(path.join(NEXT_ROOT_PATH, candidate.image)); + + return await tx + .delete(schema.candidates) + .where(eq(schema.candidates.id, input.id)); + }), + ), } as TRPCRouterRecord; diff --git a/packages/validators/src/candidate.ts b/packages/validators/src/candidate.ts index 90c4498c..f1e2991a 100644 --- a/packages/validators/src/candidate.ts +++ b/packages/validators/src/candidate.ts @@ -9,6 +9,7 @@ const ACCEPTED_IMAGE_TYPES = [ "image/webp", ]; +const id = z.number().min(1); const baseAddAndEditForm = z.object({ name: z.string().min(1, { message: "Diperlukan nama kandidat!" }), }); @@ -60,15 +61,18 @@ const UpdateCandidateSchema = baseAddAndEditForm.merge( const ServerUpdateCandidate = baseAddAndEditForm.merge( z.object({ - id: z.number().min(1), + id, image: z.optional(z.string().refine(Base64.isValid)), type: z.optional(z.string()), }), ); +const ServerDeleteCandidate = z.object({ id }); + export const candidate = { AddNewCandidateSchema, ServerAddNewCandidate, UpdateCandidateSchema, ServerUpdateCandidate, + ServerDeleteCandidate, } as const; From f7f87a681c49d7705a63309dd425b579c256baee Mon Sep 17 00:00:00 2001 From: Ezra Khairan Permana Date: Fri, 14 Jun 2024 19:39:48 +0700 Subject: [PATCH 37/44] feat: statistik esensial --- .../admin/(adminRole)/statistik/page.tsx | 10 +- .../app/_components/statistic/essential.tsx | 130 ++++++++++++++++++ packages/api/src/root.ts | 2 + packages/api/src/router/auth.ts | 4 - packages/api/src/router/statistic.ts | 37 +++++ packages/db/src/index.ts | 22 ++- 6 files changed, 199 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/app/_components/statistic/essential.tsx create mode 100644 packages/api/src/router/statistic.ts diff --git a/apps/web/src/app/(auth)/admin/(adminRole)/statistik/page.tsx b/apps/web/src/app/(auth)/admin/(adminRole)/statistik/page.tsx index 92df6b28..bf18792f 100644 --- a/apps/web/src/app/(auth)/admin/(adminRole)/statistik/page.tsx +++ b/apps/web/src/app/(auth)/admin/(adminRole)/statistik/page.tsx @@ -1,3 +1,11 @@ +import Essential from "~/app/_components/statistic/essential"; + export default function StatisticPage() { - return <>; + return ( +
+
+ +
+
+ ); } diff --git a/apps/web/src/app/_components/statistic/essential.tsx b/apps/web/src/app/_components/statistic/essential.tsx new file mode 100644 index 00000000..1b0c671b --- /dev/null +++ b/apps/web/src/app/_components/statistic/essential.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { cn } from "@sora-vp/ui"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@sora-vp/ui/card"; +import { Skeleton } from "@sora-vp/ui/skeleton"; + +import { api } from "~/trpc/react"; + +export default function Essential() { + const essentialQuery = api.statistic.essentialInfoQuery.useQuery(undefined, { + refetchInterval: 1500, + refetchIntervalInBackground: true, + }); + + return ( + <> + {essentialQuery.isLoading ? ( + <> + + + + + ) : ( + <> + + + + + )} + + ); +} + +function CandidateAccumulation(props: { count: number | null }) { + return ( + + + + Akumulasi Kandidat + + + +
+ {props.count ? ( +

+ {props.count.toLocaleString()} Orang +

+ ) : ( + <> +

+ N/A +

+

Belum di setup.

+ + )} +
+
+
+ ); +} + +function ParticipantAccumulation(props: { count: number | null }) { + return ( + + + + Akumulasi Pemilih + + + +
+ {props.count ? ( +

+ {props.count.toLocaleString()} Orang +

+ ) : ( + <> +

+ N/A +

+

Belum di setup.

+ + )} +
+
+
+ ); +} + +function IsDataMatch(props: { isMatch: boolean | null }) { + return ( + + + + Kecocokan Data + + + +
+ {props.isMatch !== null ? ( +

+ {props.isMatch ? "COCOK!" : "TIDAK COCOK!"} +

+ ) : ( + <> +

+ N/A +

+

Belum di setup.

+ + )} +
+
+
+ ); +} diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index 6bde16fe..fb280532 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -3,12 +3,14 @@ import { authRouter } from "./router/auth"; import { candidateRouter } from "./router/candidate"; import { participantRouter } from "./router/participant"; import { settingsRouter } from "./router/settings"; +import { statisticRouter } from "./router/statistic"; import { createTRPCRouter } from "./trpc"; export const appRouter = createTRPCRouter({ auth: authRouter, admin: adminRouter, candidate: candidateRouter, + statistic: statisticRouter, settings: settingsRouter, participant: participantRouter, }); diff --git a/packages/api/src/router/auth.ts b/packages/api/src/router/auth.ts index 0ac5201b..898e2976 100644 --- a/packages/api/src/router/auth.ts +++ b/packages/api/src/router/auth.ts @@ -45,8 +45,4 @@ export const authRouter = { success: true, }; }), - - getSecretMessage: protectedProcedure.query(() => { - return "you can see this secret message!"; - }), } satisfies TRPCRouterRecord; diff --git a/packages/api/src/router/statistic.ts b/packages/api/src/router/statistic.ts new file mode 100644 index 00000000..a3f21a78 --- /dev/null +++ b/packages/api/src/router/statistic.ts @@ -0,0 +1,37 @@ +import type { TRPCRouterRecord } from "@trpc/server"; + +import { + preparedGetAttendedAndVoted, + // eq, + // schema, + preparedGetCandidateCountsOnly, +} from "@sora-vp/db"; + +import { adminProcedure } from "../trpc"; + +export const statisticRouter = { + essentialInfoQuery: adminProcedure.query(async () => { + const candidates = await preparedGetCandidateCountsOnly.execute(); + + if (!candidates || candidates.length < 1) + return { + isMatch: null, + participants: null, + candidates: null, + }; + + const participantCounter = await preparedGetAttendedAndVoted.execute(); + const extractCount = participantCounter.at(0)!; + const participantsAccumulation = extractCount.count; + + const candidatesAccumulation = candidates + .map((d) => d.counter) + .reduce((curr, acc) => curr + acc); + + return { + isMatch: participantsAccumulation === candidatesAccumulation, + participants: participantsAccumulation, + candidates: candidatesAccumulation, + }; + }), +} as TRPCRouterRecord; diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index b57324cb..5c36e0fa 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,4 +1,4 @@ -import { eq, sql } from "drizzle-orm"; +import { and, count, eq, gt, sql } from "drizzle-orm"; import { drizzle } from "drizzle-orm/mysql2"; import mysql from "mysql2/promise"; @@ -50,3 +50,23 @@ export const preparedGetExcelParticipants = db.query.participants export const preparedAdminGetCandidates = db.query.candidates .findMany() .prepare(); + +export const preparedGetCandidateCountsOnly = db.query.candidates + .findMany({ + columns: { + counter: true, + }, + where: gt(schema.candidates.counter, 0), + }) + .prepare(); + +export const preparedGetAttendedAndVoted = db + .select({ count: count() }) + .from(schema.participants) + .where( + and( + eq(schema.participants.alreadyAttended, true), + eq(schema.participants.alreadyChoosing, true), + ), + ) + .prepare(); From 42a346f3f885eef0d006219a72377c514ac2c93d Mon Sep 17 00:00:00 2001 From: Ezra Khairan Permana Date: Fri, 14 Jun 2024 20:04:03 +0700 Subject: [PATCH 38/44] feat: navbar yang dapat di persist --- apps/web/src/app/(auth)/layout.tsx | 9 +++++++ .../app/_components/nav/resizeable-nav.tsx | 25 ++++++++++++++++--- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/(auth)/layout.tsx b/apps/web/src/app/(auth)/layout.tsx index 314a06e2..b4e07f5c 100644 --- a/apps/web/src/app/(auth)/layout.tsx +++ b/apps/web/src/app/(auth)/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata, Viewport } from "next"; +import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import { GeistMono } from "geist/font/mono"; import { GeistSans } from "geist/font/sans"; @@ -29,6 +30,12 @@ export const viewport: Viewport = { export default async function RootLayout(props: { children: React.ReactNode }) { const isLoggedIn = await auth(); + const layout = cookies().get("react-resizable-panels:layout"); + const collapsed = cookies().get("react-resizable-panels:collapsed"); + + const defaultLayout = layout ? JSON.parse(layout.value) : undefined; + const defaultCollapsed = collapsed ? JSON.parse(collapsed.value) : undefined; + if (!isLoggedIn) redirect("/login"); if (!isLoggedIn.user.verifiedAt) @@ -73,6 +80,8 @@ export default async function RootLayout(props: { children: React.ReactNode }) { > { + document.cookie = `react-resizable-panels:layout=${JSON.stringify( + sizes, + )}`; + }} > { setIsCollapsed(true); + document.cookie = `react-resizable-panels:collapsed=${JSON.stringify( + true, + )}`; + }} + onExpand={() => { + setIsCollapsed(false); + document.cookie = `react-resizable-panels:collapsed=${JSON.stringify( + false, + )}`; }} - onExpand={() => setIsCollapsed(false)} className={cn( isCollapsed && "min-w-[55px] transition-all duration-300 ease-in-out", )} @@ -100,7 +117,7 @@ export function ResizeableNav({
- +
{isCollapsed ? ( From 755815b993d506ad1b1b2e0650cc2041dae81641 Mon Sep 17 00:00:00 2001 From: Ezra Khairan Permana Date: Fri, 14 Jun 2024 23:04:46 +0700 Subject: [PATCH 39/44] feat: menambahkan grafik pada halaman statistik --- apps/web/package.json | 1 + .../admin/(adminRole)/statistik/page.tsx | 9 +- .../src/app/_components/statistic/graphic.tsx | 79 +++++ packages/api/src/router/statistic.ts | 13 +- packages/db/src/index.ts | 9 + yarn.lock | 307 +++++++++++++++++- 6 files changed, 413 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/app/_components/statistic/graphic.tsx diff --git a/apps/web/package.json b/apps/web/package.json index a263a52a..b891a310 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -36,6 +36,7 @@ "qrcode": "^1.5.3", "react": "18.3.1", "react-dom": "18.3.1", + "recharts": "^2.12.7", "superjson": "2.2.1", "zod": "^3.23.6" }, diff --git a/apps/web/src/app/(auth)/admin/(adminRole)/statistik/page.tsx b/apps/web/src/app/(auth)/admin/(adminRole)/statistik/page.tsx index bf18792f..410f33d5 100644 --- a/apps/web/src/app/(auth)/admin/(adminRole)/statistik/page.tsx +++ b/apps/web/src/app/(auth)/admin/(adminRole)/statistik/page.tsx @@ -1,11 +1,18 @@ import Essential from "~/app/_components/statistic/essential"; +import { Graphic } from "~/app/_components/statistic/graphic"; export default function StatisticPage() { return ( -
+
+
+ +
+ + {/* for scrollable purpose */} +
); } diff --git a/apps/web/src/app/_components/statistic/graphic.tsx b/apps/web/src/app/_components/statistic/graphic.tsx new file mode 100644 index 00000000..4fc440d2 --- /dev/null +++ b/apps/web/src/app/_components/statistic/graphic.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { FileBarChart } from "lucide-react"; +import { useTheme } from "next-themes"; +import { + Bar, + BarChart, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +import { Button } from "@sora-vp/ui/button"; +import { Skeleton } from "@sora-vp/ui/skeleton"; + +import { api } from "~/trpc/react"; + +export function Graphic() { + const { theme } = useTheme(); + + const graphicalDataQuery = api.statistic.graphicalDataQuery.useQuery( + undefined, + { + refetchInterval: 1500, + refetchIntervalInBackground: true, + }, + ); + + return ( + <> + {graphicalDataQuery.isLoading ? ( + + ) : ( +
+
+ + + + + + + + + +
+
+ +
+
+ )} + + ); +} diff --git a/packages/api/src/router/statistic.ts b/packages/api/src/router/statistic.ts index a3f21a78..7b5e1cfb 100644 --- a/packages/api/src/router/statistic.ts +++ b/packages/api/src/router/statistic.ts @@ -2,14 +2,17 @@ import type { TRPCRouterRecord } from "@trpc/server"; import { preparedGetAttendedAndVoted, - // eq, - // schema, preparedGetCandidateCountsOnly, + preparedGetGraphicalData, } from "@sora-vp/db"; import { adminProcedure } from "../trpc"; export const statisticRouter = { + graphicalDataQuery: adminProcedure.query(() => + preparedGetGraphicalData.execute(), + ), + essentialInfoQuery: adminProcedure.query(async () => { const candidates = await preparedGetCandidateCountsOnly.execute(); @@ -34,4 +37,10 @@ export const statisticRouter = { candidates: candidatesAccumulation, }; }), + + dataReportMutation: adminProcedure.mutation(async ({ ctx }) => { + return { + success: true, + }; + }), } as TRPCRouterRecord; diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 5c36e0fa..884c8b90 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -70,3 +70,12 @@ export const preparedGetAttendedAndVoted = db ), ) .prepare(); + +export const preparedGetGraphicalData = db.query.candidates + .findMany({ + columns: { + name: true, + counter: true, + }, + }) + .prepare(); diff --git a/yarn.lock b/yarn.lock index 2647fa92..b4fa0234 100644 --- a/yarn.lock +++ b/yarn.lock @@ -290,6 +290,15 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.7": + version: 7.24.7 + resolution: "@babel/runtime@npm:7.24.7" + dependencies: + regenerator-runtime: "npm:^0.14.0" + checksum: 10c0/b6fa3ec61a53402f3c1d75f4d808f48b35e0dfae0ec8e2bb5c6fc79fb95935da75766e0ca534d0f1c84871f6ae0d2ebdd950727cfadb745a2cdbef13faef5513 + languageName: node + linkType: hard + "@babel/template@npm:^7.22.15, @babel/template@npm:^7.24.0": version: 7.24.0 resolution: "@babel/template@npm:7.24.0" @@ -2934,6 +2943,7 @@ __metadata: qrcode: "npm:^1.5.3" react: "npm:18.3.1" react-dom: "npm:18.3.1" + recharts: "npm:^2.12.7" superjson: "npm:2.2.1" tailwindcss: "npm:^3.4.3" typescript: "npm:^5.4.5" @@ -3161,6 +3171,75 @@ __metadata: languageName: node linkType: hard +"@types/d3-array@npm:^3.0.3": + version: 3.2.1 + resolution: "@types/d3-array@npm:3.2.1" + checksum: 10c0/38bf2c778451f4b79ec81a2288cb4312fe3d6449ecdf562970cc339b60f280f31c93a024c7ff512607795e79d3beb0cbda123bb07010167bce32927f71364bca + languageName: node + linkType: hard + +"@types/d3-color@npm:*": + version: 3.1.3 + resolution: "@types/d3-color@npm:3.1.3" + checksum: 10c0/65eb0487de606eb5ad81735a9a5b3142d30bc5ea801ed9b14b77cb14c9b909f718c059f13af341264ee189acf171508053342142bdf99338667cea26a2d8d6ae + languageName: node + linkType: hard + +"@types/d3-ease@npm:^3.0.0": + version: 3.0.2 + resolution: "@types/d3-ease@npm:3.0.2" + checksum: 10c0/aff5a1e572a937ee9bff6465225d7ba27d5e0c976bd9eacdac2e6f10700a7cb0c9ea2597aff6b43a6ed850a3210030870238894a77ec73e309b4a9d0333f099c + languageName: node + linkType: hard + +"@types/d3-interpolate@npm:^3.0.1": + version: 3.0.4 + resolution: "@types/d3-interpolate@npm:3.0.4" + dependencies: + "@types/d3-color": "npm:*" + checksum: 10c0/066ebb8da570b518dd332df6b12ae3b1eaa0a7f4f0c702e3c57f812cf529cc3500ec2aac8dc094f31897790346c6b1ebd8cd7a077176727f4860c2b181a65ca4 + languageName: node + linkType: hard + +"@types/d3-path@npm:*": + version: 3.1.0 + resolution: "@types/d3-path@npm:3.1.0" + checksum: 10c0/85e8b3aa968a60a5b33198ade06ae7ffedcf9a22d86f24859ff58e014b053ccb7141ec163b78d547bc8215bb12bb54171c666057ab6156912814005b686afb31 + languageName: node + linkType: hard + +"@types/d3-scale@npm:^4.0.2": + version: 4.0.8 + resolution: "@types/d3-scale@npm:4.0.8" + dependencies: + "@types/d3-time": "npm:*" + checksum: 10c0/57de90e4016f640b83cb960b7e3a0ab3ed02e720898840ddc5105264ffcfea73336161442fdc91895377c2d2f91904d637282f16852b8535b77e15a761c8e99e + languageName: node + linkType: hard + +"@types/d3-shape@npm:^3.1.0": + version: 3.1.6 + resolution: "@types/d3-shape@npm:3.1.6" + dependencies: + "@types/d3-path": "npm:*" + checksum: 10c0/0625715925d3c7ed3d44ce998b42c993f063c31605b6e4a8046c4be0fe724e2d214fc83e86d04f429a30a6e1f439053e92b0d9e59e1180c3a5327b4a6e79fa0a + languageName: node + linkType: hard + +"@types/d3-time@npm:*, @types/d3-time@npm:^3.0.0": + version: 3.0.3 + resolution: "@types/d3-time@npm:3.0.3" + checksum: 10c0/245a8aadca504df27edf730de502e47a68f16ae795c86b5ca35e7afa91c133aa9ef4d08778f8cf1ed2be732f89a4105ba4b437ce2afbdfd17d3d937b6ba5f568 + languageName: node + linkType: hard + +"@types/d3-timer@npm:^3.0.0": + version: 3.0.2 + resolution: "@types/d3-timer@npm:3.0.2" + checksum: 10c0/c644dd9571fcc62b1aa12c03bcad40571553020feeb5811f1d8a937ac1e65b8a04b759b4873aef610e28b8714ac71c9885a4d6c127a048d95118f7e5b506d9e1 + languageName: node + linkType: hard + "@types/eslint@npm:^8.37.0": version: 8.56.10 resolution: "@types/eslint@npm:8.56.10" @@ -4703,6 +4782,13 @@ __metadata: languageName: node linkType: hard +"clsx@npm:^2.0.0": + version: 2.1.1 + resolution: "clsx@npm:2.1.1" + checksum: 10c0/c4c8eb865f8c82baab07e71bfa8897c73454881c4f99d6bc81585aecd7c441746c1399d08363dc096c550cceaf97bd4ce1e8854e1771e9998d9f94c4fe075839 + languageName: node + linkType: hard + "cluster-key-slot@npm:^1.1.0": version: 1.1.2 resolution: "cluster-key-slot@npm:1.1.2" @@ -4995,6 +5081,99 @@ __metadata: languageName: node linkType: hard +"d3-array@npm:2 - 3, d3-array@npm:2.10.0 - 3, d3-array@npm:^3.1.6": + version: 3.2.4 + resolution: "d3-array@npm:3.2.4" + dependencies: + internmap: "npm:1 - 2" + checksum: 10c0/08b95e91130f98c1375db0e0af718f4371ccacef7d5d257727fe74f79a24383e79aba280b9ffae655483ffbbad4fd1dec4ade0119d88c4749f388641c8bf8c50 + languageName: node + linkType: hard + +"d3-color@npm:1 - 3": + version: 3.1.0 + resolution: "d3-color@npm:3.1.0" + checksum: 10c0/a4e20e1115fa696fce041fbe13fbc80dc4c19150fa72027a7c128ade980bc0eeeba4bcf28c9e21f0bce0e0dbfe7ca5869ef67746541dcfda053e4802ad19783c + languageName: node + linkType: hard + +"d3-ease@npm:^3.0.1": + version: 3.0.1 + resolution: "d3-ease@npm:3.0.1" + checksum: 10c0/fec8ef826c0cc35cda3092c6841e07672868b1839fcaf556e19266a3a37e6bc7977d8298c0fcb9885e7799bfdcef7db1baaba9cd4dcf4bc5e952cf78574a88b0 + languageName: node + linkType: hard + +"d3-format@npm:1 - 3": + version: 3.1.0 + resolution: "d3-format@npm:3.1.0" + checksum: 10c0/049f5c0871ebce9859fc5e2f07f336b3c5bfff52a2540e0bac7e703fce567cd9346f4ad1079dd18d6f1e0eaa0599941c1810898926f10ac21a31fd0a34b4aa75 + languageName: node + linkType: hard + +"d3-interpolate@npm:1.2.0 - 3, d3-interpolate@npm:^3.0.1": + version: 3.0.1 + resolution: "d3-interpolate@npm:3.0.1" + dependencies: + d3-color: "npm:1 - 3" + checksum: 10c0/19f4b4daa8d733906671afff7767c19488f51a43d251f8b7f484d5d3cfc36c663f0a66c38fe91eee30f40327443d799be17169f55a293a3ba949e84e57a33e6a + languageName: node + linkType: hard + +"d3-path@npm:^3.1.0": + version: 3.1.0 + resolution: "d3-path@npm:3.1.0" + checksum: 10c0/dc1d58ec87fa8319bd240cf7689995111a124b141428354e9637aa83059eb12e681f77187e0ada5dedfce346f7e3d1f903467ceb41b379bfd01cd8e31721f5da + languageName: node + linkType: hard + +"d3-scale@npm:^4.0.2": + version: 4.0.2 + resolution: "d3-scale@npm:4.0.2" + dependencies: + d3-array: "npm:2.10.0 - 3" + d3-format: "npm:1 - 3" + d3-interpolate: "npm:1.2.0 - 3" + d3-time: "npm:2.1.1 - 3" + d3-time-format: "npm:2 - 4" + checksum: 10c0/65d9ad8c2641aec30ed5673a7410feb187a224d6ca8d1a520d68a7d6eac9d04caedbff4713d1e8545be33eb7fec5739983a7ab1d22d4e5ad35368c6729d362f1 + languageName: node + linkType: hard + +"d3-shape@npm:^3.1.0": + version: 3.2.0 + resolution: "d3-shape@npm:3.2.0" + dependencies: + d3-path: "npm:^3.1.0" + checksum: 10c0/f1c9d1f09926daaf6f6193ae3b4c4b5521e81da7d8902d24b38694517c7f527ce3c9a77a9d3a5722ad1e3ff355860b014557b450023d66a944eabf8cfde37132 + languageName: node + linkType: hard + +"d3-time-format@npm:2 - 4": + version: 4.1.0 + resolution: "d3-time-format@npm:4.1.0" + dependencies: + d3-time: "npm:1 - 3" + checksum: 10c0/735e00fb25a7fd5d418fac350018713ae394eefddb0d745fab12bbff0517f9cdb5f807c7bbe87bb6eeb06249662f8ea84fec075f7d0cd68609735b2ceb29d206 + languageName: node + linkType: hard + +"d3-time@npm:1 - 3, d3-time@npm:2.1.1 - 3, d3-time@npm:^3.0.0": + version: 3.1.0 + resolution: "d3-time@npm:3.1.0" + dependencies: + d3-array: "npm:2 - 3" + checksum: 10c0/a984f77e1aaeaa182679b46fbf57eceb6ebdb5f67d7578d6f68ef933f8eeb63737c0949991618a8d29472dbf43736c7d7f17c452b2770f8c1271191cba724ca1 + languageName: node + linkType: hard + +"d3-timer@npm:^3.0.1": + version: 3.0.1 + resolution: "d3-timer@npm:3.0.1" + checksum: 10c0/d4c63cb4bb5461d7038aac561b097cd1c5673969b27cbdd0e87fa48d9300a538b9e6f39b4a7f0e3592ef4f963d858c8a9f0e92754db73116770856f2fc04561a + languageName: node + linkType: hard + "d@npm:1, d@npm:^1.0.1, d@npm:^1.0.2": version: 1.0.2 resolution: "d@npm:1.0.2" @@ -5128,6 +5307,13 @@ __metadata: languageName: node linkType: hard +"decimal.js-light@npm:^2.4.1": + version: 2.5.1 + resolution: "decimal.js-light@npm:2.5.1" + checksum: 10c0/4fd33f535aac9e5bd832796831b65d9ec7914ad129c7437b3ab991b0c2eaaa5a57e654e6174c4a17f1b3895ea366f0c1ab4955cdcdf7cfdcf3ad5a58b456c020 + languageName: node + linkType: hard + "deep-extend@npm:^0.6.0": version: 0.6.0 resolution: "deep-extend@npm:0.6.0" @@ -5341,6 +5527,16 @@ __metadata: languageName: node linkType: hard +"dom-helpers@npm:^5.0.1": + version: 5.2.1 + resolution: "dom-helpers@npm:5.2.1" + dependencies: + "@babel/runtime": "npm:^7.8.7" + csstype: "npm:^3.0.2" + checksum: 10c0/f735074d66dd759b36b158fa26e9d00c9388ee0e8c9b16af941c38f014a37fc80782de83afefd621681b19ac0501034b4f1c4a3bff5caa1b8667f0212b5e124c + languageName: node + linkType: hard + "dot-case@npm:^2.1.0": version: 2.1.1 resolution: "dot-case@npm:2.1.1" @@ -6492,6 +6688,13 @@ __metadata: languageName: node linkType: hard +"eventemitter3@npm:^4.0.1": + version: 4.0.7 + resolution: "eventemitter3@npm:4.0.7" + checksum: 10c0/5f6d97cbcbac47be798e6355e3a7639a84ee1f7d9b199a07017f1d2f1e2fe236004d14fa5dfaeba661f94ea57805385e326236a6debbc7145c8877fbc0297c6b + languageName: node + linkType: hard + "events@npm:^3.3.0": version: 3.3.0 resolution: "events@npm:3.3.0" @@ -6601,6 +6804,13 @@ __metadata: languageName: node linkType: hard +"fast-equals@npm:^5.0.1": + version: 5.0.1 + resolution: "fast-equals@npm:5.0.1" + checksum: 10c0/d7077b8b681036c2840ed9860a3048e44fc268fad2b525b8f25b43458be0c8ad976152eb4b475de9617170423c5b802121ebb61ed6641c3ac035fadaf805c8c0 + languageName: node + linkType: hard + "fast-fifo@npm:^1.1.0, fast-fifo@npm:^1.2.0": version: 1.3.2 resolution: "fast-fifo@npm:1.3.2" @@ -7587,6 +7797,13 @@ __metadata: languageName: node linkType: hard +"internmap@npm:1 - 2": + version: 2.0.3 + resolution: "internmap@npm:2.0.3" + checksum: 10c0/8cedd57f07bbc22501516fbfc70447f0c6812871d471096fad9ea603516eacc2137b633633daf432c029712df0baefd793686388ddf5737e3ea15074b877f7ed + languageName: node + linkType: hard + "invariant@npm:^2.2.4": version: 2.2.4 resolution: "invariant@npm:2.2.4" @@ -10285,7 +10502,7 @@ __metadata: languageName: node linkType: hard -"prop-types@npm:^15.8.1": +"prop-types@npm:^15.6.2, prop-types@npm:^15.8.1": version: 15.8.1 resolution: "prop-types@npm:15.8.1" dependencies: @@ -10446,7 +10663,7 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^16.13.1": +"react-is@npm:^16.10.2, react-is@npm:^16.13.1": version: 16.13.1 resolution: "react-is@npm:16.13.1" checksum: 10c0/33977da7a5f1a287936a0c85639fec6ca74f4f15ef1e59a6bc20338fc73dc69555381e211f7a3529b8150a1f71e4225525b41b60b52965bda53ce7d47377ada1 @@ -10498,6 +10715,20 @@ __metadata: languageName: node linkType: hard +"react-smooth@npm:^4.0.0": + version: 4.0.1 + resolution: "react-smooth@npm:4.0.1" + dependencies: + fast-equals: "npm:^5.0.1" + prop-types: "npm:^15.8.1" + react-transition-group: "npm:^4.4.5" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + checksum: 10c0/5c19a2c147798c3de1329d442b1a371139c01113cc108c38c201b63502c329f943ede505c44089d26a6563eaa72a67b845d538d956f34a389b37fd3961308834 + languageName: node + linkType: hard + "react-style-singleton@npm:^2.2.1": version: 2.2.1 resolution: "react-style-singleton@npm:2.2.1" @@ -10515,6 +10746,21 @@ __metadata: languageName: node linkType: hard +"react-transition-group@npm:^4.4.5": + version: 4.4.5 + resolution: "react-transition-group@npm:4.4.5" + dependencies: + "@babel/runtime": "npm:^7.5.5" + dom-helpers: "npm:^5.0.1" + loose-envify: "npm:^1.4.0" + prop-types: "npm:^15.6.2" + peerDependencies: + react: ">=16.6.0" + react-dom: ">=16.6.0" + checksum: 10c0/2ba754ba748faefa15f87c96dfa700d5525054a0141de8c75763aae6734af0740e77e11261a1e8f4ffc08fd9ab78510122e05c21c2d79066c38bb6861a886c82 + languageName: node + linkType: hard + "react@npm:18.3.1": version: 18.3.1 resolution: "react@npm:18.3.1" @@ -10609,6 +10855,34 @@ __metadata: languageName: node linkType: hard +"recharts-scale@npm:^0.4.4": + version: 0.4.5 + resolution: "recharts-scale@npm:0.4.5" + dependencies: + decimal.js-light: "npm:^2.4.1" + checksum: 10c0/64ce1fc4ebe62001787bf4dc4cbb779452d33831619309c71c50277c58e8968ffe98941562d9d0d5ffdb02588ebd62f4fe6548fa826110fd458db9c3cc6dadc1 + languageName: node + linkType: hard + +"recharts@npm:^2.12.7": + version: 2.12.7 + resolution: "recharts@npm:2.12.7" + dependencies: + clsx: "npm:^2.0.0" + eventemitter3: "npm:^4.0.1" + lodash: "npm:^4.17.21" + react-is: "npm:^16.10.2" + react-smooth: "npm:^4.0.0" + recharts-scale: "npm:^0.4.4" + tiny-invariant: "npm:^1.3.1" + victory-vendor: "npm:^36.6.8" + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 + checksum: 10c0/2522d841a1f4e4c0a37046ddb61fa958ac37a66df63dcd4c6cb9113e3f7a71892d74e44494a55bc40faa0afd74d9cf58fec3d2ce53a8ddf997e75367bdd033fc + languageName: node + linkType: hard + "redis-errors@npm:^1.0.0, redis-errors@npm:^1.2.0": version: 1.2.0 resolution: "redis-errors@npm:1.2.0" @@ -11830,6 +12104,13 @@ __metadata: languageName: node linkType: hard +"tiny-invariant@npm:^1.3.1": + version: 1.3.3 + resolution: "tiny-invariant@npm:1.3.3" + checksum: 10c0/65af4a07324b591a059b35269cd696aba21bef2107f29b9f5894d83cc143159a204b299553435b03874ebb5b94d019afa8b8eff241c8a4cfee95872c2e1c1c4a + languageName: node + linkType: hard + "tinycolor2@npm:^1.0.0": version: 1.6.0 resolution: "tinycolor2@npm:1.6.0" @@ -12642,6 +12923,28 @@ __metadata: languageName: node linkType: hard +"victory-vendor@npm:^36.6.8": + version: 36.9.2 + resolution: "victory-vendor@npm:36.9.2" + dependencies: + "@types/d3-array": "npm:^3.0.3" + "@types/d3-ease": "npm:^3.0.0" + "@types/d3-interpolate": "npm:^3.0.1" + "@types/d3-scale": "npm:^4.0.2" + "@types/d3-shape": "npm:^3.1.0" + "@types/d3-time": "npm:^3.0.0" + "@types/d3-timer": "npm:^3.0.0" + d3-array: "npm:^3.1.6" + d3-ease: "npm:^3.0.1" + d3-interpolate: "npm:^3.0.1" + d3-scale: "npm:^4.0.2" + d3-shape: "npm:^3.1.0" + d3-time: "npm:^3.0.0" + d3-timer: "npm:^3.0.1" + checksum: 10c0/bad36de3bf4d406834743c2e99a8281d786af324d7e84b7f7a2fc02c27a3779034fb0c3c4707d4c8e68683334d924a67100cfa13985235565e83b9877f8e2ffd + languageName: node + linkType: hard + "wcwidth@npm:^1.0.1": version: 1.0.1 resolution: "wcwidth@npm:1.0.1" From 3dae1ae65a1c4227deb8cbb2f819594a90948831 Mon Sep 17 00:00:00 2001 From: Ezra Khairan Permana Date: Sun, 16 Jun 2024 06:50:30 +0700 Subject: [PATCH 40/44] feat: menambahkan presentasi pemilih --- .../src/app/_components/statistic/graphic.tsx | 145 +++++++++++++----- 1 file changed, 104 insertions(+), 41 deletions(-) diff --git a/apps/web/src/app/_components/statistic/graphic.tsx b/apps/web/src/app/_components/statistic/graphic.tsx index 4fc440d2..d781a5ad 100644 --- a/apps/web/src/app/_components/statistic/graphic.tsx +++ b/apps/web/src/app/_components/statistic/graphic.tsx @@ -1,11 +1,11 @@ "use client"; +import { useMemo } from "react"; import { FileBarChart } from "lucide-react"; import { useTheme } from "next-themes"; import { Bar, BarChart, - Legend, ResponsiveContainer, Tooltip, XAxis, @@ -28,51 +28,114 @@ export function Graphic() { }, ); + const percentageData = useMemo(() => { + if (!graphicalDataQuery.data || graphicalDataQuery.data?.length < 1) + return null; + + const allCounter = graphicalDataQuery.data.map((d) => d.counter); + + const totalCalc = allCounter.reduce((curr, acc) => curr + acc, 0); + const percentages = allCounter.map((count, idx) => ({ + idx, + count: count.toLocaleString("id-ID"), + percentage: `${Math.round((count / totalCalc) * 100)}%`, + })); + + if (totalCalc === 0) return []; + + return percentages; + }, [graphicalDataQuery.data]); + return ( <> {graphicalDataQuery.isLoading ? ( ) : ( -
-
- - - - - - - - - -
-
- -
-
+ <> + {graphicalDataQuery.data.length < 1 ? ( +
+

+ N/A +

+
+ ) : ( +
+
+ + + + + + + + +
+
+
+

+ Presentase Pemilihan +

+ +

+ {!percentageData ? ( + "N/A." + ) : percentageData.length < 1 ? ( + "Belum ada yang memilih." + ) : ( + <> + {percentageData.map((data) => ( + <> + {data.idx > 0 ? ( + <> + - + + {data.percentage} + {data.count} + + + ) : ( + + {data.percentage} + {data.count} + + )} + + ))} + + )} +

+
+ + +
+
+ )} + )} ); From 8b4a00e31313675e4c43dbfe49b68c2a6ad1702b Mon Sep 17 00:00:00 2001 From: Ezra Khairan Permana Date: Sun, 16 Jun 2024 07:04:11 +0700 Subject: [PATCH 41/44] feat: nanti dulu --- apps/web/src/app/_components/statistic/graphic.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/web/src/app/_components/statistic/graphic.tsx b/apps/web/src/app/_components/statistic/graphic.tsx index d781a5ad..6c54be9d 100644 --- a/apps/web/src/app/_components/statistic/graphic.tsx +++ b/apps/web/src/app/_components/statistic/graphic.tsx @@ -12,7 +12,7 @@ import { YAxis, } from "recharts"; -import { Button } from "@sora-vp/ui/button"; +// import { Button } from "@sora-vp/ui/button"; import { Skeleton } from "@sora-vp/ui/skeleton"; import { api } from "~/trpc/react"; @@ -95,10 +95,10 @@ export function Graphic() {

- Presentase Pemilihan + Persentase Pemilihan

-

+

{!percentageData ? ( "N/A." ) : percentageData.length < 1 ? ( @@ -123,15 +123,16 @@ export function Graphic() { )} ))} + {" ."} )}

- + */}
)} From 19ba794749d1b9b9080080a9f7e73b2a6f347745 Mon Sep 17 00:00:00 2001 From: Ezra Khairan Permana Date: Sun, 16 Jun 2024 07:17:04 +0700 Subject: [PATCH 42/44] feat: menambahkan ci --- .github/actions/yarn-nm-instal/action.yml | 127 ++++++++++++++++++++++ .github/workflows/ci.yml | 50 +++++++++ tooling/github/package.json | 3 - tooling/github/setup/action.yml | 17 --- 4 files changed, 177 insertions(+), 20 deletions(-) create mode 100644 .github/actions/yarn-nm-instal/action.yml create mode 100644 .github/workflows/ci.yml delete mode 100644 tooling/github/package.json delete mode 100644 tooling/github/setup/action.yml diff --git a/.github/actions/yarn-nm-instal/action.yml b/.github/actions/yarn-nm-instal/action.yml new file mode 100644 index 00000000..cc279415 --- /dev/null +++ b/.github/actions/yarn-nm-instal/action.yml @@ -0,0 +1,127 @@ +# https://github.com/belgattitude/nextjs-monorepo-example/blob/7a28e746859024989a02b7d2ecbd8b061292c277/.github/actions/yarn-nm-install/action.yml + +######################################################################################## +# "yarn install" composite action for yarn 3/4+ and "nodeLinker: node-modules" # +#--------------------------------------------------------------------------------------# +# Requirement: @setup/node should be run before # +# # +# Usage in workflows steps: # +# # +# - name: 📥 Monorepo install # +# uses: ./.github/actions/yarn-nm-install # +# with: # +# enable-corepack: false # (default = 'false') # +# cache-npm-cache: false # (default = 'true') # +# cwd: ${{ github.workspace }}/apps/my-app # (default = '.') # +# cache-prefix: add cache key prefix # (default = 'default') # +# cache-node-modules: false # (default = 'false') # +# cache-install-state: false # (default = 'false') # +# # +# Reference: # +# - latest: https://gist.github.com/belgattitude/042f9caf10d029badbde6cf9d43e400a # +# # +# Versions: # +# - 1.2.0 - 01-05-2024 - action/cache upraded to v4 # +# - 1.1.0 - 22-07-2023 - Option to enable npm global cache folder. # +# - 1.0.4 - 15-07-2023 - Fix corepack was always enabled. # +# - 1.0.3 - 05-07-2023 - YARN_ENABLE_MIRROR to false (speed up cold start) # +# - 1.0.2 - 02-06-2023 - install-state default to false # +# - 1.0.1 - 29-05-2023 - cache-prefix doc # +# - 1.0.0 - 27-05-2023 - new input: cache-prefix # +######################################################################################## + +name: 'Monorepo install (yarn)' +description: 'Run yarn install with node_modules linker and cache enabled' +inputs: + cwd: + description: "Changes node's process.cwd() if the project is not located on the root. Default to process.cwd()" + required: false + default: '.' + cache-prefix: + description: 'Add a specific cache-prefix' + required: false + default: 'default' + cache-npm-cache: + description: 'Cache npm global cache folder often used by node-gyp, prebuild binaries (invalidated on lock/os/node-version)' + required: false + default: 'true' + cache-node-modules: + description: 'Cache node_modules, might speed up link step (invalidated lock/os/node-version/branch)' + required: false + default: 'false' + cache-install-state: + description: 'Cache yarn install state, might speed up resolution step when node-modules cache is activated (invalidated lock/os/node-version/branch)' + required: false + default: 'false' + enable-corepack: + description: 'Enable corepack' + required: false + default: 'true' + +runs: + using: 'composite' + + steps: + - name: ⚙️ Enable Corepack + if: inputs.enable-corepack == 'true' + shell: bash + working-directory: ${{ inputs.cwd }} + run: corepack enable + + - name: ⚙️ Expose yarn config as "$GITHUB_OUTPUT" + id: yarn-config + shell: bash + working-directory: ${{ inputs.cwd }} + env: + YARN_ENABLE_GLOBAL_CACHE: 'false' + run: | + echo "CACHE_FOLDER=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT + echo "CURRENT_NODE_VERSION="node-$(node --version)"" >> $GITHUB_OUTPUT + echo "CURRENT_BRANCH=$(echo ${GITHUB_REF#refs/heads/} | sed -r 's,/,-,g')" >> $GITHUB_OUTPUT + echo "NPM_GLOBAL_CACHE_FOLDER=$(npm config get cache)" >> $GITHUB_OUTPUT + + - name: ♻️ Restore yarn cache + uses: actions/cache@v4 + id: yarn-download-cache + with: + path: ${{ steps.yarn-config.outputs.CACHE_FOLDER }} + key: yarn-download-cache-${{ inputs.cache-prefix }}-${{ hashFiles(format('{0}/yarn.lock', inputs.cwd), format('{0}/.yarnrc.yml', inputs.cwd)) }} + restore-keys: | + yarn-download-cache-${{ inputs.cache-prefix }}- + + - name: ♻️ Restore node_modules + if: inputs.cache-node-modules == 'true' + id: yarn-nm-cache + uses: actions/cache@v4 + with: + path: ${{ inputs.cwd }}/**/node_modules + key: yarn-nm-cache-${{ inputs.cache-prefix }}-${{ runner.os }}-${{ steps.yarn-config.outputs.CURRENT_NODE_VERSION }}-${{ steps.yarn-config.outputs.CURRENT_BRANCH }}-${{ hashFiles(format('{0}/yarn.lock', inputs.cwd), format('{0}/.yarnrc.yml', inputs.cwd)) }} + + - name: ♻️ Restore global npm cache folder + if: inputs.cache-npm-cache == 'true' + id: npm-global-cache + uses: actions/cache@v4 + with: + path: ${{ steps.yarn-config.outputs.NPM_GLOBAL_CACHE_FOLDER }} + key: npm-global-cache-${{ inputs.cache-prefix }}-${{ runner.os }}-${{ steps.yarn-config.outputs.CURRENT_NODE_VERSION }}-${{ hashFiles(format('{0}/yarn.lock', inputs.cwd), format('{0}/.yarnrc.yml', inputs.cwd)) }} + + - name: ♻️ Restore yarn install state + if: inputs.cache-install-state == 'true' && inputs.cache-node-modules == 'true' + id: yarn-install-state-cache + uses: actions/cache@v4 + with: + path: ${{ inputs.cwd }}/.yarn/ci-cache + key: yarn-install-state-cache-${{ inputs.cache-prefix }}-${{ runner.os }}-${{ steps.yarn-config.outputs.CURRENT_NODE_VERSION }}-${{ steps.yarn-config.outputs.CURRENT_BRANCH }}-${{ hashFiles(format('{0}/yarn.lock', inputs.cwd), format('{0}/.yarnrc.yml', inputs.cwd)) }} + + - name: 📥 Install dependencies + shell: bash + working-directory: ${{ inputs.cwd }} + run: yarn install --immutable --inline-builds + env: + # Overrides/align yarnrc.yml options (v3, v4) for a CI context + YARN_ENABLE_GLOBAL_CACHE: 'false' # Use local cache folder to keep downloaded archives + YARN_ENABLE_MIRROR: 'false' # Prevent populating global cache for caches misses (local cache only) + YARN_NM_MODE: 'hardlinks-local' # Reduce node_modules size + YARN_INSTALL_STATE_PATH: '.yarn/ci-cache/install-state.gz' # Might speed up resolution step when node_modules present + # Other environment variables + HUSKY: '0' # By default do not run HUSKY install diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..dcc444d4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +on: + pull_request: + branches: ["*"] + push: + branches: ["main"] + merge_group: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Monorepo install + uses: ./.github/actions/yarn-nm-install + + - name: Copy env + shell: bash + run: cp .env.example .env + + - name: Lint + run: yarn lint && pnpm lint:ws + + format: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Monorepo install + uses: ./.github/actions/yarn-nm-install + + - name: Format + run: yarn format + + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Monorepo install + uses: ./.github/actions/yarn-nm-install + + - name: Typecheck + run: turbo typecheck diff --git a/tooling/github/package.json b/tooling/github/package.json deleted file mode 100644 index 46326a3e..00000000 --- a/tooling/github/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "name": "@sora-vp/github" -} diff --git a/tooling/github/setup/action.yml b/tooling/github/setup/action.yml deleted file mode 100644 index 9ef92c44..00000000 --- a/tooling/github/setup/action.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: "Setup and install" -description: "Common setup steps for Actions" - -runs: - using: composite - steps: - - uses: pnpm/action-setup@v2 - - uses: actions/setup-node@v4 - with: - node-version: 18 - cache: "pnpm" - - - shell: bash - run: pnpm add -g turbo - - - shell: bash - run: pnpm install From 194d7daf38ca290b63b6c77151e9796a39be154a Mon Sep 17 00:00:00 2001 From: Ezra Khairan Permana Date: Sun, 16 Jun 2024 07:20:12 +0700 Subject: [PATCH 43/44] fix: memperbaiki nama direktori yang salah --- .github/actions/{yarn-nm-instal => yarn-nm-install}/action.yml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/actions/{yarn-nm-instal => yarn-nm-install}/action.yml (100%) diff --git a/.github/actions/yarn-nm-instal/action.yml b/.github/actions/yarn-nm-install/action.yml similarity index 100% rename from .github/actions/yarn-nm-instal/action.yml rename to .github/actions/yarn-nm-install/action.yml From 5a82b4b26c254684f3302f30c418a46223829c7a Mon Sep 17 00:00:00 2001 From: Ezra Khairan Permana Date: Sun, 16 Jun 2024 07:22:01 +0700 Subject: [PATCH 44/44] fix: lockfile --- yarn.lock | 6 ------ 1 file changed, 6 deletions(-) diff --git a/yarn.lock b/yarn.lock index b4fa0234..1f653492 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2752,12 +2752,6 @@ __metadata: languageName: unknown linkType: soft -"@sora-vp/github@workspace:tooling/github": - version: 0.0.0-use.local - resolution: "@sora-vp/github@workspace:tooling/github" - languageName: unknown - linkType: soft - "@sora-vp/id-generator@npm:*, @sora-vp/id-generator@workspace:packages/id-generator": version: 0.0.0-use.local resolution: "@sora-vp/id-generator@workspace:packages/id-generator"