diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a08092d1a..9eefdc7bf 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -18,7 +18,7 @@ jobs: run: npm install --legacy-peer-deps - name: Run linting check - run: npm run lint:check + run: npm run lint:fix - name: Check formatting - run: npm run format:check \ No newline at end of file + run: npm run format:fix \ No newline at end of file diff --git a/.prettierrc b/.prettierrc index 1a9abe1c7..68a46e1f4 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,5 +1,5 @@ { - "semi": false, + "semi": true, "singleQuote": true, "tabWidth": 2, "trailingComma": "all" diff --git a/package.json b/package.json index 6766aada3..5b7f4bf02 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@discordjs/next": "^0.1.1-dev.1673526225-a580768.0", "@prisma/client": "^5.6.0", "@radix-ui/react-accordion": "^1.1.2", + "@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-label": "^2.0.2", @@ -28,6 +29,7 @@ "axios": "^1.6.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", + "dayjs": "^1.11.10", "discord-oauth2": "^2.11.0", "discord.js": "^14.14.1", "jsonwebtoken": "^9.0.2", @@ -42,6 +44,7 @@ "react-notion-x": "^6.16.0", "react-resizable-panels": "^1.0.7", "recoil": "^0.7.7", + "sharp": "^0.33.2", "sonner": "^1.4.0", "tailwind-merge": "^2.2.1", "tailwindcss-animate": "^1.0.7", @@ -49,7 +52,8 @@ "video.js": "^8.6.1", "videojs-contrib-eme": "^3.11.1", "videojs-mobile-ui": "^1.1.1", - "videojs-sprite-thumbnails": "^2.1.1" + "videojs-sprite-thumbnails": "^2.1.1", + "zod": "^3.22.4" }, "devDependencies": { "@types/node": "^20", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40cf2f90c..ec207e127 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ dependencies: '@radix-ui/react-accordion': specifier: ^1.1.2 version: 1.1.2(@types/react-dom@18.2.18)(@types/react@18.2.51)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-avatar': + specifier: ^1.0.4 + version: 1.0.4(@types/react-dom@18.2.18)(@types/react@18.2.51)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-dialog': specifier: ^1.0.5 version: 1.0.5(@types/react-dom@18.2.18)(@types/react@18.2.51)(react-dom@18.2.0)(react@18.2.0) @@ -47,6 +50,9 @@ dependencies: clsx: specifier: ^2.1.0 version: 2.1.0 + dayjs: + specifier: ^1.11.10 + version: 1.11.10 discord-oauth2: specifier: ^2.11.0 version: 2.12.0 @@ -89,6 +95,9 @@ dependencies: recoil: specifier: ^0.7.7 version: 0.7.7(react-dom@18.2.0)(react@18.2.0) + sharp: + specifier: ^0.33.2 + version: 0.33.2 sonner: specifier: ^1.4.0 version: 1.4.0(react-dom@18.2.0)(react@18.2.0) @@ -113,6 +122,9 @@ dependencies: videojs-sprite-thumbnails: specifier: ^2.1.1 version: 2.2.1 + zod: + specifier: ^3.22.4 + version: 3.22.4 devDependencies: '@types/node': @@ -363,6 +375,14 @@ packages: - utf-8-validate dev: false + /@emnapi/runtime@0.45.0: + resolution: {integrity: sha512-Txumi3td7J4A/xTTwlssKieHKTGl3j4A1tglBx72auZ49YK7ePY6XZricgIg9mnZT4xPfA+UPCUdnhRuEFDL+w==} + requiresBuild: true + dependencies: + tslib: 2.6.2 + dev: false + optional: true + /@eslint-community/eslint-utils@4.4.0(eslint@8.56.0): resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -457,6 +477,194 @@ packages: resolution: {integrity: sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==} dev: true + /@img/sharp-darwin-arm64@0.33.2: + resolution: {integrity: sha512-itHBs1rPmsmGF9p4qRe++CzCgd+kFYktnsoR1sbIAfsRMrJZau0Tt1AH9KVnufc2/tU02Gf6Ibujx+15qRE03w==} + engines: {glibc: '>=2.26', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.0.1 + dev: false + optional: true + + /@img/sharp-darwin-x64@0.33.2: + resolution: {integrity: sha512-/rK/69Rrp9x5kaWBjVN07KixZanRr+W1OiyKdXcbjQD6KbW+obaTeBBtLUAtbBsnlTTmWthw99xqoOS7SsySDg==} + engines: {glibc: '>=2.26', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [x64] + os: [darwin] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.0.1 + dev: false + optional: true + + /@img/sharp-libvips-darwin-arm64@1.0.1: + resolution: {integrity: sha512-kQyrSNd6lmBV7O0BUiyu/OEw9yeNGFbQhbxswS1i6rMDwBBSX+e+rPzu3S+MwAiGU3HdLze3PanQ4Xkfemgzcw==} + engines: {macos: '>=11', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + + /@img/sharp-libvips-darwin-x64@1.0.1: + resolution: {integrity: sha512-eVU/JYLPVjhhrd8Tk6gosl5pVlvsqiFlt50wotCvdkFGf+mDNBJxMh+bvav+Wt3EBnNZWq8Sp2I7XfSjm8siog==} + engines: {macos: '>=10.13', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + + /@img/sharp-libvips-linux-arm64@1.0.1: + resolution: {integrity: sha512-bnGG+MJjdX70mAQcSLxgeJco11G+MxTz+ebxlz8Y3dxyeb3Nkl7LgLI0mXupoO+u1wRNx/iRj5yHtzA4sde1yA==} + engines: {glibc: '>=2.26', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@img/sharp-libvips-linux-arm@1.0.1: + resolution: {integrity: sha512-FtdMvR4R99FTsD53IA3LxYGghQ82t3yt0ZQ93WMZ2xV3dqrb0E8zq4VHaTOuLEAuA83oDawHV3fd+BsAPadHIQ==} + engines: {glibc: '>=2.28', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@img/sharp-libvips-linux-s390x@1.0.1: + resolution: {integrity: sha512-3+rzfAR1YpMOeA2zZNp+aYEzGNWK4zF3+sdMxuCS3ey9HhDbJ66w6hDSHDMoap32DueFwhhs3vwooAB2MaK4XQ==} + engines: {glibc: '>=2.28', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@img/sharp-libvips-linux-x64@1.0.1: + resolution: {integrity: sha512-3NR1mxFsaSgMMzz1bAnnKbSAI+lHXVTqAHgc1bgzjHuXjo4hlscpUxc0vFSAPKI3yuzdzcZOkq7nDPrP2F8Jgw==} + engines: {glibc: '>=2.26', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@img/sharp-libvips-linuxmusl-arm64@1.0.1: + resolution: {integrity: sha512-5aBRcjHDG/T6jwC3Edl3lP8nl9U2Yo8+oTl5drd1dh9Z1EBfzUKAJFUDTDisDjUwc7N4AjnPGfCA3jl3hY8uDg==} + engines: {musl: '>=1.2.2', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@img/sharp-libvips-linuxmusl-x64@1.0.1: + resolution: {integrity: sha512-dcT7inI9DBFK6ovfeWRe3hG30h51cBAP5JXlZfx6pzc/Mnf9HFCQDLtYf4MCBjxaaTfjCCjkBxcy3XzOAo5txw==} + engines: {musl: '>=1.2.2', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@img/sharp-linux-arm64@0.33.2: + resolution: {integrity: sha512-pz0NNo882vVfqJ0yNInuG9YH71smP4gRSdeL09ukC2YLE6ZyZePAlWKEHgAzJGTiOh8Qkaov6mMIMlEhmLdKew==} + engines: {glibc: '>=2.26', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [arm64] + os: [linux] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.0.1 + dev: false + optional: true + + /@img/sharp-linux-arm@0.33.2: + resolution: {integrity: sha512-Fndk/4Zq3vAc4G/qyfXASbS3HBZbKrlnKZLEJzPLrXoJuipFNNwTes71+Ki1hwYW5lch26niRYoZFAtZVf3EGA==} + engines: {glibc: '>=2.28', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [arm] + os: [linux] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.0.1 + dev: false + optional: true + + /@img/sharp-linux-s390x@0.33.2: + resolution: {integrity: sha512-MBoInDXDppMfhSzbMmOQtGfloVAflS2rP1qPcUIiITMi36Mm5YR7r0ASND99razjQUpHTzjrU1flO76hKvP5RA==} + engines: {glibc: '>=2.28', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [s390x] + os: [linux] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.0.1 + dev: false + optional: true + + /@img/sharp-linux-x64@0.33.2: + resolution: {integrity: sha512-xUT82H5IbXewKkeF5aiooajoO1tQV4PnKfS/OZtb5DDdxS/FCI/uXTVZ35GQ97RZXsycojz/AJ0asoz6p2/H/A==} + engines: {glibc: '>=2.26', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [x64] + os: [linux] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.0.1 + dev: false + optional: true + + /@img/sharp-linuxmusl-arm64@0.33.2: + resolution: {integrity: sha512-F+0z8JCu/UnMzg8IYW1TMeiViIWBVg7IWP6nE0p5S5EPQxlLd76c8jYemG21X99UzFwgkRo5yz2DS+zbrnxZeA==} + engines: {musl: '>=1.2.2', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [arm64] + os: [linux] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.0.1 + dev: false + optional: true + + /@img/sharp-linuxmusl-x64@0.33.2: + resolution: {integrity: sha512-+ZLE3SQmSL+Fn1gmSaM8uFusW5Y3J9VOf+wMGNnTtJUMUxFhv+P4UPaYEYT8tqnyYVaOVGgMN/zsOxn9pSsO2A==} + engines: {musl: '>=1.2.2', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [x64] + os: [linux] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.0.1 + dev: false + optional: true + + /@img/sharp-wasm32@0.33.2: + resolution: {integrity: sha512-fLbTaESVKuQcpm8ffgBD7jLb/CQLcATju/jxtTXR1XCLwbOQt+OL5zPHSDMmp2JZIeq82e18yE0Vv7zh6+6BfQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [wasm32] + requiresBuild: true + dependencies: + '@emnapi/runtime': 0.45.0 + dev: false + optional: true + + /@img/sharp-win32-ia32@0.33.2: + resolution: {integrity: sha512-okBpql96hIGuZ4lN3+nsAjGeggxKm7hIRu9zyec0lnfB8E7Z6p95BuRZzDDXZOl2e8UmR4RhYt631i7mfmKU8g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: false + optional: true + + /@img/sharp-win32-x64@0.33.2: + resolution: {integrity: sha512-E4magOks77DK47FwHUIGH0RYWSgRBfGdK56kIHSVeB9uIS4pPFr4N2kIVsXdQQo4LzOsENKV5KAhRlRL7eMAdg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: false + optional: true + /@isaacs/cliui@8.0.2: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -720,6 +928,30 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@radix-ui/react-avatar@1.0.4(@types/react-dom@18.2.18)(@types/react@18.2.51)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-kVK2K7ZD3wwj3qhle0ElXhOjbezIgyl2hVvgwfIdexL3rN6zJmy5AqqIf+D31lxVppdzV8CjAfZ6PklkmInZLw==} + 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 + dependencies: + '@babel/runtime': 7.23.9 + '@radix-ui/react-context': 1.0.1(@types/react@18.2.51)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.18)(@types/react@18.2.51)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.51)(react@18.2.0) + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.51)(react@18.2.0) + '@types/react': 18.2.51 + '@types/react-dom': 18.2.18 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-collapsible@1.0.3(@types/react-dom@18.2.18)(@types/react@18.2.51)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-UBmVDkmR6IvDsloHVN+3rtx4Mi5TFvylYXpluuv0f37dtaz3H99bp8No0LGXRigVpl3UAT4l9j6bIchh42S/Gg==} peerDependencies: @@ -2046,6 +2278,21 @@ packages: /color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + /color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.2 + dev: false + + /color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + dev: false + /combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -2121,6 +2368,10 @@ packages: engines: {node: '>= 12'} dev: false + /dayjs@1.11.10: + resolution: {integrity: sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==} + dev: false + /debug@4.3.4: resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} engines: {node: '>=6.0'} @@ -2154,6 +2405,11 @@ packages: engines: {node: '>=0.4.0'} dev: false + /detect-libc@2.0.2: + resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==} + engines: {node: '>=8'} + dev: false + /detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} dev: false @@ -2740,6 +2996,10 @@ packages: loose-envify: 1.4.0 dev: false + /is-arrayish@0.3.2: + resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} + dev: false + /is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} @@ -4053,6 +4313,36 @@ packages: engines: {node: '>=6.9'} dev: false + /sharp@0.33.2: + resolution: {integrity: sha512-WlYOPyyPDiiM07j/UO+E720ju6gtNtHjEGg5vovUk1Lgxyjm2LFO+37Nt/UI3MMh2l6hxTWQWi7qk3cXJTutcQ==} + engines: {libvips: '>=8.15.1', node: ^18.17.0 || ^20.3.0 || >=21.0.0} + requiresBuild: true + dependencies: + color: 4.2.3 + detect-libc: 2.0.2 + semver: 7.5.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.33.2 + '@img/sharp-darwin-x64': 0.33.2 + '@img/sharp-libvips-darwin-arm64': 1.0.1 + '@img/sharp-libvips-darwin-x64': 1.0.1 + '@img/sharp-libvips-linux-arm': 1.0.1 + '@img/sharp-libvips-linux-arm64': 1.0.1 + '@img/sharp-libvips-linux-s390x': 1.0.1 + '@img/sharp-libvips-linux-x64': 1.0.1 + '@img/sharp-libvips-linuxmusl-arm64': 1.0.1 + '@img/sharp-libvips-linuxmusl-x64': 1.0.1 + '@img/sharp-linux-arm': 0.33.2 + '@img/sharp-linux-arm64': 0.33.2 + '@img/sharp-linux-s390x': 0.33.2 + '@img/sharp-linux-x64': 0.33.2 + '@img/sharp-linuxmusl-arm64': 0.33.2 + '@img/sharp-linuxmusl-x64': 0.33.2 + '@img/sharp-wasm32': 0.33.2 + '@img/sharp-win32-ia32': 0.33.2 + '@img/sharp-win32-x64': 0.33.2 + dev: false + /shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -4067,6 +4357,12 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + /simple-swizzle@0.2.2: + resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + dependencies: + is-arrayish: 0.3.2 + dev: false + /slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -4688,3 +4984,7 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} dev: true + + /zod@3.22.4: + resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} + dev: false diff --git a/prisma/migrations/20231119045842_init/migration.sql b/prisma/migrations/20231119045842_init/migration.sql deleted file mode 100644 index 72c618677..000000000 --- a/prisma/migrations/20231119045842_init/migration.sql +++ /dev/null @@ -1,35 +0,0 @@ --- CreateTable -CREATE TABLE "User" ( - "id" SERIAL NOT NULL, - "email" TEXT NOT NULL, - "name" TEXT, - - CONSTRAINT "User_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Course" ( - "id" SERIAL NOT NULL, - "title" TEXT NOT NULL, - "imageUrl" TEXT NOT NULL, - - CONSTRAINT "Course_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "UserPurchases" ( - "userId" INTEGER NOT NULL, - "courseId" INTEGER NOT NULL, - "assignedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "UserPurchases_pkey" PRIMARY KEY ("userId","courseId") -); - --- CreateIndex -CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); - --- AddForeignKey -ALTER TABLE "UserPurchases" ADD CONSTRAINT "UserPurchases_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "UserPurchases" ADD CONSTRAINT "UserPurchases_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fa3303c72..b60dbd284 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -46,6 +46,8 @@ model Content { VideoMetadata VideoMetadata? NotionMetadata NotionMetadata? notionMetadataId Int? + comments Comment[] + commentsCount Int @default(0) } model CourseContent { @@ -118,6 +120,8 @@ model User { sessions Session[] purchases UserPurchases[] videoProgress VideoProgress[] + comments Comment[] + votes Vote[] discordConnect DiscordConnect? disableDrm Boolean @default(false) } @@ -148,3 +152,45 @@ model VideoProgress { @@unique([contentId, userId]) } + +model Comment { + id Int @id @default(autoincrement()) + content String + commentType CommentType @default(DEFAULT) + approved Boolean @default(false) + contentId Int + commentedOn Content @relation(fields: [contentId], references: [id]) + parentId Int? + parent Comment? @relation("ParentComment", fields: [parentId], references: [id]) + children Comment[] @relation("ParentComment") + userId String + user User @relation(fields: [userId], references: [id]) + upvotes Int @default(0) + downvotes Int @default(0) + repliesCount Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + votes Vote[] +} + + +model Vote { + id Int @id @default(autoincrement()) + commentId Int + comment Comment @relation(fields: [commentId], references: [id]) + userId String + user User @relation(fields: [userId], references: [id]) + voteType VoteType // enum + createdAt DateTime @default(now()) + @@unique([commentId, userId]) +} + +enum VoteType { + UPVOTE + DOWNVOTE +} + +enum CommentType { + INTRO + DEFAULT +} \ No newline at end of file diff --git a/src/actions/comment/index.ts b/src/actions/comment/index.ts new file mode 100644 index 000000000..63fc8921e --- /dev/null +++ b/src/actions/comment/index.ts @@ -0,0 +1,379 @@ +'use server'; +import { getServerSession } from 'next-auth'; +import { + InputTypeApproveIntroComment, + InputTypeCreateComment, + InputTypeDeleteComment, + InputTypeUpdateComment, + ReturnTypeApproveIntroComment, + ReturnTypeCreateComment, + ReturnTypeDeleteComment, + ReturnTypeUpdateComment, +} from './types'; +import { authOptions } from '@/lib/auth'; +import { rateLimit } from '@/lib/utils'; +import prisma from '@/db'; +import { + CommentApproveIntroSchema, + CommentDeleteSchema, + CommentInsertSchema, + CommentUpdateSchema, +} from './schema'; +import { createSafeAction } from '@/lib/create-safe-action'; +import { CommentType, Prisma } from '@prisma/client'; +import { revalidatePath } from 'next/cache'; + +export const getComments = async ( + q: Prisma.CommentFindManyArgs, + parentId: number | null | undefined, +) => { + let parentComment = null; + if (parentId) { + parentComment = await prisma.comment.findUnique({ + where: { id: parseInt(parentId.toString(), 10) }, + include: { + user: true, + }, + }); + } + if (!parentComment) { + delete q.where?.parentId; + } + + const comments = await prisma.comment.findMany(q); + + return { + comments, + parentComment, + }; +}; +const parseIntroComment = ( + comment: string, +): Array<{ start: number; end?: number; title: string }> | null => { + const introPattern = /^intro:\s*([\s\S]*)$/; + const match = comment.match(introPattern); + if (!match) return []; + + const lines = match[1].split('\n').filter((line) => line.trim() !== ''); + const segments = lines.map((line) => { + const parts = line.split('-').map((part) => part.trim()); + const timePattern = /(\d{2}):(\d{2})/; + const startTimeMatch = parts[0].match(timePattern); + + const start = startTimeMatch + ? parseInt(startTimeMatch[1], 10) * 60 + parseInt(startTimeMatch[2], 10) + : 0; + const title = parts.length > 2 ? parts[2] : parts[1]; + + return { start, title, end: 0 }; + }); + + // Set 'end' to the 'start' of the next segment + for (let i = 0; i < segments.length - 1; i++) { + segments[i].end = segments[i + 1].start; + } + + // Handle the last segment + const lastLineParts = lines[lines.length - 1] + .split('-') + .map((part) => part.trim()); + if (lastLineParts.length < 3) { + return null; + } + const timePattern = /(\d{2}):(\d{2})/; + const endTimeMatch = lastLineParts[1].match(timePattern); + const end = endTimeMatch + ? parseInt(endTimeMatch[1], 10) * 60 + parseInt(endTimeMatch[2], 10) + : 0; + segments[segments.length - 1].end = end; + + return segments; +}; +const createCommentHandler = async ( + data: InputTypeCreateComment, +): Promise => { + const session = await getServerSession(authOptions); + + if (!session || !session.user) { + return { error: 'Unauthorized or insufficient permissions' }; + } + + const { content, contentId, parentId } = data; + const userId = session.user.id; + + if (!rateLimit(userId)) { + return { error: 'Rate limit exceeded. Please try again later.' }; + } + + try { + // Check if the parent comment exists and is a top-level comment + // Only top-level comments can have replies like youtube comments otherwise it would be a thread + let parentComment; + if (parentId) { + parentComment = await prisma.comment.findUnique({ + where: { id: parentId }, + }); + + if (!parentComment) { + return { error: 'Parent comment not found.' }; + } + + if (parentComment.parentId) { + return { error: 'Cannot reply to a nested comment.' }; + } + } + + // Check if the related content exists and is not a folder + // We only allow comments on videos and Notion pages + const relatedContent = await prisma.content.findUnique({ + where: { id: contentId }, + }); + + if (!relatedContent || relatedContent.type === 'folder') { + return { error: 'Invalid content for commenting.' }; + } + let comment; + if (parentComment) { + await prisma.$transaction(async (prisma) => { + comment = await prisma.comment.create({ + data: { + content, + contentId, + parentId, // undefined if its a comment without parent (top level) + userId, + }, + }); + await prisma.comment.update({ + where: { id: parentId }, + data: { + repliesCount: { increment: 1 }, + }, + }); + }); + } else { + await prisma.$transaction(async (prisma) => { + let introData: + | { start: number; end?: number | undefined; title: string }[] + | null = []; + if (data.content.startsWith('intro:')) { + introData = parseIntroComment(data.content); + if ( + !introData || + introData.length === 0 || + introData[introData.length - 1].end === 0 + ) { + throw new Error( + 'Invalid intro comment format, remember to include end time on the segment. Example: 12:24- 23:43 - Introduction to the course', + ); + } + // Here you might want to store introData in a specific way, depending on your needs + } + + comment = await prisma.comment.create({ + data: { + content, + contentId, + parentId, // undefined if its a comment without parent (top level) + userId, + commentType: + introData && introData.length > 0 + ? CommentType.INTRO + : CommentType.DEFAULT, + }, + }); + await prisma.content.update({ + where: { id: contentId }, + data: { + commentsCount: { increment: 1 }, + }, + }); + }); + } + if (data.currentPath) { + revalidatePath(data.currentPath); + } + return { data: comment }; + } catch (error: any) { + return { error: error.message || 'Failed to create comment.' }; + } +}; +const updateCommentHandler = async ( + data: InputTypeUpdateComment, +): Promise => { + const session = await getServerSession(authOptions); + + if (!session || !session.user) { + return { error: 'Unauthorized or insufficient permissions' }; + } + + const { commentId, content, approved, adminPassword } = data; + const userId = session.user.id; + + try { + const existingComment = await prisma.comment.findUnique({ + where: { id: commentId }, + }); + + if (!existingComment) { + return { error: 'Comment not found.' }; + } + + // only the user who created the comment can update it + if (existingComment.userId !== userId) { + return { error: 'Unauthorized to update this comment.' }; + } + + // Update the comment but if its admin we need to check if the comment is approved + const updObj = { + content: content ?? existingComment.content, + approved: existingComment.approved, + }; + if (adminPassword === process.env.ADMIN_SECRET) { + updObj.approved = approved ?? existingComment.approved; + } + const updatedComment = await prisma.comment.update({ + where: { id: commentId }, + data: updObj, + }); + if (data.currentPath) { + revalidatePath(data.currentPath); + } + return { data: updatedComment }; + } catch (error) { + return { error: 'Failed to update comment.' }; + } +}; +const approveIntroCommentHandler = async ( + data: InputTypeApproveIntroComment, +): Promise => { + const { content_comment_ids, approved, adminPassword } = data; + + if (adminPassword !== process.env.ADMIN_SECRET) { + return { error: 'Unauthorized' }; + } + + const [contentId, commentId] = content_comment_ids.split(';'); + try { + const existingComment = await prisma.comment.findUnique({ + where: { id: parseInt(commentId, 10) }, + }); + + if (!existingComment) { + return { error: 'Comment not found.' }; + } + + const introData = parseIntroComment(existingComment.content); + + if ( + !introData || + introData.length === 0 || + existingComment.commentType !== CommentType.INTRO + ) { + return { + error: + 'Comment is not an intro comment or can not be parsed. Plese check that last segment has end time include.', + }; + } + // Update the comment but if its admin we need to check if the comment is approved + const updObj = { + approved, + }; + let updatedComment = null; + await prisma.$transaction(async (prisma) => { + updatedComment = await prisma.comment.update({ + where: { id: parseInt(commentId, 10) }, + data: updObj, + }); + await prisma.videoMetadata.update({ + where: { + contentId: Number(contentId), + }, + data: { + segments: introData, + }, + }); + }); + + return { data: updatedComment! }; + } catch (error) { + return { error: 'Failed to update comment.' }; + } +}; + +const deleteCommentHandler = async ( + data: InputTypeDeleteComment, +): Promise => { + const session = await getServerSession(authOptions); + + if (!session || !session.user) { + return { error: 'Unauthorized or insufficient permissions' }; + } + + const { commentId, adminPassword } = data; + const userId = session.user.id; + + try { + const existingComment = await prisma.comment.findUnique({ + where: { id: commentId }, + }); + + if (!existingComment) { + return { error: 'Comment not found.' }; + } + + if ( + existingComment.userId !== userId && + adminPassword !== process.env.ADMIN_SECRET + ) { + return { error: 'Unauthorized to delete this comment.' }; + } + + // if there is no parentId we know that its a top level comment + // so lets delete the children aslo + // lets do this in a transaction so we can rollback if something goes wrong + await prisma.$transaction(async (prisma) => { + // delete also votes so they are not orphaned + await prisma.vote.deleteMany({ + where: { + OR: [{ commentId }, { comment: { parentId: commentId } }], + }, + }); + + if (!existingComment.parentId) { + await prisma.comment.deleteMany({ + where: { parentId: commentId }, + }); + } + + // Then delete the comment itself + await prisma.comment.delete({ + where: { id: commentId }, + }); + }); + if (data.currentPath) { + revalidatePath(data.currentPath); + } + return { + data: { message: 'Comment and its replies deleted successfully' }, + }; + } catch (error) { + return { error: 'Failed to delete comment.' }; + } +}; + +export const createMessage = createSafeAction( + CommentInsertSchema, + createCommentHandler, +); +export const updateMessage = createSafeAction( + CommentUpdateSchema, + updateCommentHandler, +); +export const deleteMessage = createSafeAction( + CommentDeleteSchema, + deleteCommentHandler, +); +export const approveComment = createSafeAction( + CommentApproveIntroSchema, + approveIntroCommentHandler, +); diff --git a/src/actions/comment/schema.ts b/src/actions/comment/schema.ts new file mode 100644 index 000000000..34163be1e --- /dev/null +++ b/src/actions/comment/schema.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +export const CommentInsertSchema = z.object({ + content: z.string().min(1, 'Comment content is required'), + contentId: z.number(), + parentId: z.number().optional(), + currentPath: z.string().optional(), +}); + +export const CommentUpdateSchema = z.object({ + commentId: z.number(), + content: z.string().optional(), + // upVotes: z.number().optional(), + // downVotes: z.number().optional(), + approved: z.boolean().optional(), + adminPassword: z.string().optional(), + currentPath: z.string().optional(), +}); +export const CommentApproveIntroSchema = z.object({ + content_comment_ids: z.string(), + approved: z.boolean().optional(), + adminPassword: z.string().optional(), +}); +export const CommentDeleteSchema = z.object({ + adminPassword: z.string().optional(), + commentId: z.number(), + currentPath: z.string().optional(), +}); diff --git a/src/actions/comment/types.ts b/src/actions/comment/types.ts new file mode 100644 index 000000000..1c2ff6339 --- /dev/null +++ b/src/actions/comment/types.ts @@ -0,0 +1,39 @@ +import { ActionState } from '@/lib/create-safe-action'; +import { z } from 'zod'; +import { + CommentInsertSchema, + CommentUpdateSchema, + CommentDeleteSchema, + CommentApproveIntroSchema, +} from './schema'; +import { Delete } from '../types'; +import { User, Comment } from '@prisma/client'; + +export type InputTypeCreateComment = z.infer; +export type ReturnTypeCreateComment = ActionState< + InputTypeCreateComment, + Comment +>; + +export type InputTypeUpdateComment = z.infer; +export type ReturnTypeUpdateComment = ActionState< + InputTypeUpdateComment, + Comment +>; +export type InputTypeApproveIntroComment = z.infer< + typeof CommentApproveIntroSchema +>; +export type ReturnTypeApproveIntroComment = ActionState< + InputTypeApproveIntroComment, + Comment +>; + +export type InputTypeDeleteComment = z.infer; +export type ReturnTypeDeleteComment = ActionState< + InputTypeDeleteComment, + Delete +>; + +export interface ExtendedComment extends Comment { + user: User; +} diff --git a/src/actions/commentVote/index.ts b/src/actions/commentVote/index.ts new file mode 100644 index 000000000..34530dcfb --- /dev/null +++ b/src/actions/commentVote/index.ts @@ -0,0 +1,119 @@ +'use server'; + +import { getServerSession } from 'next-auth'; +import { InputTypeHandleVote, ReturnTypeHandleVote } from './types'; +import { authOptions } from '@/lib/auth'; +import prisma from '@/db'; +import { VoteType } from '@prisma/client'; +import { revalidatePath } from 'next/cache'; +import { createSafeAction } from '@/lib/create-safe-action'; +import { VoteHandleSchema } from './schema'; + +const voteHandler = async ( + data: InputTypeHandleVote, +): Promise => { + const session = await getServerSession(authOptions); + + if (!session || !session.user.id) { + return { error: 'Unauthorized' }; + } + + const { commentId, voteType } = data; + + try { + const userExists = await prisma.user.findUnique({ + where: { id: session.user.id }, + }); + + if (!userExists) { + return { error: 'User not found.' }; + } + await prisma.$transaction(async (prisma) => { + const existingVote = await prisma.vote.findFirst({ + where: { + userId: session.user.id, + commentId, + }, + }); + + if (existingVote) { + if (existingVote.voteType === voteType) { + // toggle off the vote + await prisma.vote.delete({ + where: { + id: existingVote.id, + }, + }); + + // Decrement the vote count + const updateField = + voteType === VoteType.UPVOTE ? 'upvotes' : 'downvotes'; + await prisma.comment.update({ + where: { id: commentId }, + data: { + [updateField]: { decrement: 1 }, + }, + }); + } else { + // Update the existing vote + await prisma.vote.update({ + where: { + id: existingVote.id, + }, + data: { + voteType, + }, + }); + + // adjust the prev vote and the new vote + await prisma.comment.update({ + where: { id: commentId }, + data: { + upvotes: + voteType === VoteType.UPVOTE + ? { increment: 1 } + : { decrement: 1 }, + downvotes: + voteType === VoteType.DOWNVOTE + ? { increment: 1 } + : { decrement: 1 }, + }, + }); + } + } else { + // Create a new vote + await prisma.vote.create({ + data: { + voteType, + userId: session.user.id!, + commentId, + }, + }); + + // Increment the vote count + const updateField = + voteType === VoteType.UPVOTE ? 'upvotes' : 'downvotes'; + await prisma.comment.update({ + where: { id: commentId }, + data: { + [updateField]: { increment: 1 }, + }, + }); + } + }); + + const updatedComment = await prisma.comment.findUnique({ + where: { id: commentId }, + }); + revalidatePath(data.currentPath); + return { data: updatedComment }; + } catch (error) { + console.error(error); + return { error: 'Failed to process the vote.' }; + } +}; + +export const voteHandlerAction = createSafeAction( + VoteHandleSchema, + voteHandler, +); diff --git a/src/actions/commentVote/schema.ts b/src/actions/commentVote/schema.ts new file mode 100644 index 000000000..a0c6c12e0 --- /dev/null +++ b/src/actions/commentVote/schema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; +import { VoteType } from '@prisma/client'; // Assuming VoteType is an enum in Prisma + +export const VoteHandleSchema = z.object({ + commentId: z.number(), + voteType: z.nativeEnum(VoteType), + currentPath: z.string(), +}); diff --git a/src/actions/commentVote/types.ts b/src/actions/commentVote/types.ts new file mode 100644 index 000000000..a25a71024 --- /dev/null +++ b/src/actions/commentVote/types.ts @@ -0,0 +1,10 @@ +import { Comment } from '@prisma/client'; +import { ActionState } from '@/lib/create-safe-action'; +import { z } from 'zod'; +import { VoteHandleSchema } from './schema'; + +export type InputTypeHandleVote = z.infer; +export type ReturnTypeHandleVote = ActionState< + InputTypeHandleVote, + Comment | null +>; diff --git a/src/actions/types.ts b/src/actions/types.ts new file mode 100644 index 000000000..1570de0fc --- /dev/null +++ b/src/actions/types.ts @@ -0,0 +1,22 @@ +import { CommentType } from '@prisma/client'; + +export interface QueryParams { + limit?: number; + page?: number; + commentfilter?: CommentFilter; + search?: string; + date?: string; + type?: CommentType; + parentId?: number; + userId?: number; + commentId?: number; + timestamp?: number; +} +export enum CommentFilter { + md = 'Most downvotes', + mu = 'Most upvotes', + mr = 'Most Recent', +} +export type Delete = { + message: string; +}; diff --git a/src/app/admin/comment/ApproveComment.tsx b/src/app/admin/comment/ApproveComment.tsx new file mode 100644 index 000000000..bee046f8d --- /dev/null +++ b/src/app/admin/comment/ApproveComment.tsx @@ -0,0 +1,79 @@ +'use client'; +import { approveComment } from '@/actions/comment'; +import { FormErrors } from '@/components/FormError'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { useAction } from '@/hooks/useAction'; +import { Label } from '@radix-ui/react-dropdown-menu'; +import React from 'react'; +import { toast } from 'sonner'; + +const ApproveComment = () => { + const formRef = React.useRef(null); + const { execute, fieldErrors } = useAction(approveComment, { + onSuccess: () => { + toast('Comment added'); + formRef.current?.reset(); + }, + onError: (error) => { + toast.error(error); + }, + }); + + const handleApprove = (e: React.FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.target as HTMLFormElement); + + const commentId = formData.get('commentId') as string; + const adminPassword = formData.get('adminPassword') as string; + execute({ + content_comment_ids: commentId, + adminPassword, + approved: true, + }); + }; + return ( +
+
+
+
Approve Intro comment
+
+ Enter the information below to approve the comment +
+
+ +
+ + + +
+
+ + + +
+
+ +
+
+
+ ); +}; + +export default ApproveComment; diff --git a/src/app/admin/comment/page.tsx b/src/app/admin/comment/page.tsx new file mode 100644 index 000000000..11f993f7d --- /dev/null +++ b/src/app/admin/comment/page.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import ApproveComment from './ApproveComment'; + +const CommentAdminPage = () => { + return ( +
+ +
+ ); +}; + +export default CommentAdminPage; diff --git a/src/app/admin/content/[...courseId]/page.tsx b/src/app/admin/content/[...courseId]/page.tsx index 962f10812..c21da5cdf 100644 --- a/src/app/admin/content/[...courseId]/page.tsx +++ b/src/app/admin/content/[...courseId]/page.tsx @@ -1,11 +1,15 @@ -import { getCourse, getCourseContent, getCurrentContentType } from '@/db/course'; +import { + getCourse, + getCourseContent, + getCurrentContentType, +} from '@/db/course'; import { AddContent } from '@/components/admin/AddContent'; import { AdminCourseContent } from '@/components/admin/CourseContent'; export default async function UpdateCourseContent({ params, }: { - params: { courseId: string[] } + params: { courseId: string[] }; }) { const courseId = params.courseId[0]; const rest = params.courseId.slice(1); diff --git a/src/app/api/admin/content/route.ts b/src/app/api/admin/content/route.ts index c80506656..7f7c6a1f3 100644 --- a/src/app/api/admin/content/route.ts +++ b/src/app/api/admin/content/route.ts @@ -11,13 +11,13 @@ export const POST = async (req: NextRequest) => { metadata, adminPassword, }: { - type: 'video' | 'folder' | 'notion' - thumbnail: string - title: string - courseId: number - parentContentId: number - metadata: any - adminPassword: string + type: 'video' | 'folder' | 'notion'; + thumbnail: string; + title: string; + courseId: number; + parentContentId: number; + metadata: any; + adminPassword: string; } = await req.json(); if (adminPassword !== process.env.ADMIN_SECRET) { diff --git a/src/app/courses/[...courseId]/page.tsx b/src/app/courses/[...courseId]/page.tsx index ed2d98e36..aeb91435a 100644 --- a/src/app/courses/[...courseId]/page.tsx +++ b/src/app/courses/[...courseId]/page.tsx @@ -12,6 +12,7 @@ import { authOptions } from '@/lib/auth'; import { getPurchases } from '@/utiles/appx'; import { redirect } from 'next/navigation'; import { CourseView } from '@/components/CourseView'; +import { QueryParams } from '@/actions/types'; const checkAccess = async (courseId: string) => { const session = await getServerSession(authOptions); @@ -50,11 +51,14 @@ function findContentById( export default async function Course({ params, + searchParams, }: { - params: { courseId: string[] } + params: { courseId: string[] }; + searchParams: QueryParams; }) { const courseId = params.courseId[0]; const rest = params.courseId.slice(1); + const possiblePath = params.courseId.join('/'); const hasAccess = await checkAccess(courseId); const course = await getCourse(parseInt(courseId, 10)); const fullCourseContent: Folder[] = await getFullCourseContent( @@ -81,6 +85,8 @@ export default async function Course({ nextContent={nextContent} courseContent={courseContent} fullCourseContent={fullCourseContent} + searchParams={searchParams} + possiblePath={possiblePath} /> ); diff --git a/src/app/courses/loading.tsx b/src/app/courses/loading.tsx index 6c3ea8027..d32e207d2 100644 --- a/src/app/courses/loading.tsx +++ b/src/app/courses/loading.tsx @@ -5,8 +5,8 @@ import { CourseSkeleton } from '@/components/CourseCard'; export default function Loading() { return (
- {[1, 2, 3].map(() => ( - + {[1, 2, 3].map((v) => ( + ))}
); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 32f935cdb..daecca454 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -29,7 +29,7 @@ export const metadata: Metadata = { export default function RootLayout({ children, }: { - children: React.ReactNode + children: React.ReactNode; }) { return ( diff --git a/src/app/page.tsx b/src/app/page.tsx index f3e41389e..1ff51ee2b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -11,12 +11,12 @@ const rs = Poppins({ }); const getUserDetails = async () => { - console.log('get user details start'); - const date = new Date(); + // console.log('get user details start'); + // const date = new Date(); const session = await getServerSession(authOptions); - console.log( - `get user details end ${ (new Date().getTime() - date.getTime()) / 1000}`, - ); + // console.log( + // `get user details end ${ (new Date().getTime() - date.getTime()) / 1000}`, + // ); return session; }; diff --git a/src/components/3dcard.tsx b/src/components/3dcard.tsx index d93900b9b..e7af41af6 100644 --- a/src/components/3dcard.tsx +++ b/src/components/3dcard.tsx @@ -21,9 +21,9 @@ export const CardContainer = ({ className, containerClassName, }: { - children?: React.ReactNode - className?: string - containerClassName?: string + children?: React.ReactNode; + className?: string; + containerClassName?: string; }) => { const containerRef = useRef(null); const [isMouseEntered, setIsMouseEntered] = useState(false); @@ -82,8 +82,8 @@ export const CardBody = ({ children, className, }: { - children: React.ReactNode - className?: string + children: React.ReactNode; + className?: string; }) => { return (
{ const ref = useRef(null); const [isMouseEntered] = useMouseEnter(); diff --git a/src/components/ContentCard.tsx b/src/components/ContentCard.tsx index 4da8c2962..ffb9bac9d 100644 --- a/src/components/ContentCard.tsx +++ b/src/components/ContentCard.tsx @@ -8,12 +8,12 @@ export const ContentCard = ({ markAsCompleted, percentComplete, }: { - contentId?: number - image: string - title: string - onClick: () => void - markAsCompleted?: boolean - percentComplete?: number | null + contentId?: number; + image: string; + title: string; + onClick: () => void; + markAsCompleted?: boolean; + percentComplete?: number | null; }) => { return (
{ + const handleCopyClick = () => { + try { + navigator.clipboard.writeText(textToCopy); + toast.success('Copied to clipboard'); + } catch (error) { + toast.error('Failed to copy to clipboard'); + } + }; + + return ( +
+ +
+ ); +}; + +export default CopyToClipboard; diff --git a/src/components/CourseCard.tsx b/src/components/CourseCard.tsx index 592ef5d98..8fdaf8ee1 100644 --- a/src/components/CourseCard.tsx +++ b/src/components/CourseCard.tsx @@ -8,8 +8,8 @@ export const CourseCard = ({ course, onClick, }: { - course: Course - onClick: () => void + course: Course; + onClick: () => void; }) => { return (
{ return (
@@ -43,10 +49,20 @@ export const CourseView = ({ }} /> ) : null} + {(contentType === 'video' || contentType === 'notion') && ( + + )} {contentType === 'folder' ? ( ({ + courseContent={courseContent?.map((x: any) => ({ title: x?.title || '', image: x?.thumbnail || '', id: x?.id || 0, diff --git a/src/components/FolderView.tsx b/src/components/FolderView.tsx index bb418e4e5..5996319dc 100644 --- a/src/components/FolderView.tsx +++ b/src/components/FolderView.tsx @@ -7,19 +7,19 @@ export const FolderView = ({ courseId, rest, }: { - courseId: number - rest: string[] + courseId: number; + rest: string[]; courseContent: { - title: string - image: string - id: number - markAsCompleted: boolean - percentComplete: number | null - }[] + title: string; + image: string; + id: number; + markAsCompleted: boolean; + percentComplete: number | null; + }[]; }) => { const router = useRouter(); - if (!courseContent.length) { + if (!courseContent?.length) { return (
No content here yet!
diff --git a/src/components/FormError.tsx b/src/components/FormError.tsx new file mode 100644 index 000000000..715aa9124 --- /dev/null +++ b/src/components/FormError.tsx @@ -0,0 +1,30 @@ +import { XCircle } from 'lucide-react'; + +interface FormErrorsProps { + id: string; + errors?: Record; +} + +export const FormErrors = ({ id, errors }: FormErrorsProps) => { + if (!errors) { + return null; + } + + return ( +
+ {errors?.[id]?.map((error: string) => ( +
+ + {error} +
+ ))} +
+ ); +}; diff --git a/src/components/JoinDiscord.tsx b/src/components/JoinDiscord.tsx index 6c62f435d..b7a815415 100644 --- a/src/components/JoinDiscord.tsx +++ b/src/components/JoinDiscord.tsx @@ -10,8 +10,8 @@ export const JoinDiscord = ({ isNavigated = true, isInMenu = false, }: { - isNavigated?: boolean - isInMenu?: boolean + isNavigated?: boolean; + isInMenu?: boolean; }) => { if (isNavigated) { return ( diff --git a/src/components/Pagination.tsx b/src/components/Pagination.tsx new file mode 100644 index 000000000..e2ede4305 --- /dev/null +++ b/src/components/Pagination.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { + getUpdatedUrl, + paginationData, + searchParamsToObject, +} from '@/lib/utils'; +import Link from 'next/link'; +import { usePathname, useSearchParams } from 'next/navigation'; +import React from 'react'; +interface IPagination { + dataLength: number; +} +const Pagination: React.FC = ({ dataLength = 1 }) => { + const searchParams = useSearchParams(); + const path = usePathname(); + const paramsObj = searchParamsToObject(searchParams); + const paginationQ = paginationData(paramsObj); + return ( +
+ {paginationQ.pageNumber > 1 && ( + + + + + + + {paginationQ.pageNumber - 1} + + )} + + + {paginationQ.pageNumber} + + {dataLength >= paginationQ.pageSize && ( + + + + + + + {paginationQ.pageNumber + 1} + + )} +
+ ); +}; + +export default Pagination; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 55c1d3cd1..48924a2d8 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -18,8 +18,8 @@ export function Sidebar({ courseId, fullCourseContent, }: { - fullCourseContent: Folder[] - courseId: string + fullCourseContent: Folder[]; + courseId: string; }) { const router = useRouter(); const [sidebarOpen, setSidebarOpen] = useRecoilState(sidebarOpenAtom); @@ -41,7 +41,11 @@ export function Sidebar({ return newPath; } if (content.children) { - const childPath = findPathToContent(content.children, targetId, newPath); + const childPath = findPathToContent( + content.children, + targetId, + newPath, + ); if (childPath) { return childPath; } @@ -134,8 +138,8 @@ export function ToggleButton({ onClick, sidebarOpen, }: { - onClick: () => void - sidebarOpen: boolean + onClick: () => void; + sidebarOpen: boolean; }) { return (
diff --git a/src/components/VideoPlayer.tsx b/src/components/VideoPlayer.tsx index 3456acacc..ef1cbb434 100644 --- a/src/components/VideoPlayer.tsx +++ b/src/components/VideoPlayer.tsx @@ -9,9 +9,9 @@ export const VideoPlayer = ({ mpdUrl, subtitles, }: { - mpdUrl: string - thumbnail: string - subtitles: string + mpdUrl: string; + thumbnail: string; + subtitles: string; }) => { const videoRef = useRef(null); const [player, setPlayer] = useState(null); diff --git a/src/components/VideoPlayer2.tsx b/src/components/VideoPlayer2.tsx index 851fde495..6e7795c46 100644 --- a/src/components/VideoPlayer2.tsx +++ b/src/components/VideoPlayer2.tsx @@ -8,14 +8,15 @@ import 'videojs-mobile-ui/dist/videojs-mobile-ui.css'; import 'videojs-mobile-ui'; import 'videojs-sprite-thumbnails'; import { handleMarkAsCompleted } from '@/lib/utils'; +import { useSearchParams } from 'next/navigation'; // todo correct types interface VideoPlayerProps { - options: any - onReady?: (player: Player) => void - subtitles?: string - contentId: number - onVideoEnd: () => void + options: any; + onReady?: (player: Player) => void; + subtitles?: string; + contentId: number; + onVideoEnd: () => void; } const PLAYBACK_RATES: number[] = [0.5, 1, 1.25, 1.5, 1.75, 2]; @@ -31,7 +32,7 @@ export const VideoPlayer: FunctionComponent = ({ const videoRef = useRef(null); const playerRef = useRef(null); const [player, setPlayer] = useState(null); - + const searchParams = useSearchParams(); useEffect(() => { if (contentId && player) { fetch(`/api/course/videoProgress?contentId=${contentId}`).then( @@ -106,6 +107,16 @@ export const VideoPlayer: FunctionComponent = ({ break; } } else { + const activeElement = document.activeElement; + + // Check if there is an active element and if it's an input or textarea + if ( + activeElement && + (activeElement.tagName.toLowerCase() === 'input' || + activeElement.tagName.toLowerCase() === 'textarea') + ) { + return; // Do nothing if the active element is an input or textarea + } switch (event.code) { case 'Space': // Space bar for play/pause if (player.paused()) { @@ -248,6 +259,13 @@ export const VideoPlayer: FunctionComponent = ({ }; }, []); + useEffect(() => { + const t = searchParams.get('timestamp'); + + if (playerRef.current && t) { + playerRef.current.currentTime(parseInt(t, 10)); + } + }, [searchParams, playerRef.current]); return (
diff --git a/src/components/VideoPlayerSegment.tsx b/src/components/VideoPlayerSegment.tsx index a545a5b7e..141916f4c 100644 --- a/src/components/VideoPlayerSegment.tsx +++ b/src/components/VideoPlayerSegment.tsx @@ -10,20 +10,20 @@ import { Segment } from '@/lib/utils'; import Player from 'video.js/dist/types/player'; export interface Thumbnail { - public_id: string - version: number - url: string - secure_url: string - timestamp: number + public_id: string; + version: number; + url: string; + secure_url: string; + timestamp: number; } interface VideoProps { - thumbnails: Thumbnail[] - segments: Segment[] - subtitles: string - videoJsOptions: any - contentId: number - onVideoEnd: () => void + thumbnails: Thumbnail[]; + segments: Segment[]; + subtitles: string; + videoJsOptions: any; + contentId: number; + onVideoEnd: () => void; } export const VideoPlayerSegment: FunctionComponent = ({ diff --git a/src/components/admin/AddContent.tsx b/src/components/admin/AddContent.tsx index f05c3ee5e..681464995 100644 --- a/src/components/admin/AddContent.tsx +++ b/src/components/admin/AddContent.tsx @@ -6,8 +6,8 @@ export const AddContent = ({ courseId, parentContentId, }: { - courseId: number - parentContentId?: number + courseId: number; + parentContentId?: number; }) => { const [type, setType] = useState('folder'); const [imageUri, setImageUri] = useState(''); @@ -93,7 +93,7 @@ const VARIANTS = 1; function AddVideosMetadata({ onChange, }: { - onChange: (metadata: any) => void + onChange: (metadata: any) => void; }) { const [metadataGlobal, setMetadata] = useState({} as any); diff --git a/src/components/admin/AddNotionMetadata.tsx b/src/components/admin/AddNotionMetadata.tsx index 2a7710270..ec5d32b83 100644 --- a/src/components/admin/AddNotionMetadata.tsx +++ b/src/components/admin/AddNotionMetadata.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'; export function AddNotionMetadata({ onChange, }: { - onChange: (metadata: any) => void + onChange: (metadata: any) => void; }) { const [id, setId] = useState(''); diff --git a/src/components/admin/ContentRenderer.tsx b/src/components/admin/ContentRenderer.tsx index 7e31ed9ad..662fb7565 100644 --- a/src/components/admin/ContentRenderer.tsx +++ b/src/components/admin/ContentRenderer.tsx @@ -62,19 +62,19 @@ export const ContentRenderer = async ({ nextContent, }: { nextContent: { - id: number - type: string - title: string - } | null + id: number; + type: string; + title: string; + } | null; content: { - type: 'video' - id: number - title: string - description: string - thumbnail: string - slides?: string - markAsCompleted: boolean - } + type: 'video'; + id: number; + title: string; + description: string; + thumbnail: string; + slides?: string; + markAsCompleted: boolean; + }; }) => { const metadata = await getMetadata(content.id); diff --git a/src/components/admin/ContentRendererClient.tsx b/src/components/admin/ContentRendererClient.tsx index 6125a4de5..abae412ae 100644 --- a/src/components/admin/ContentRendererClient.tsx +++ b/src/components/admin/ContentRendererClient.tsx @@ -12,19 +12,19 @@ export const ContentRendererClient = ({ nextContent, }: { nextContent: { - id: number - type: string - title: string - } | null - metadata: any + id: number; + type: string; + title: string; + } | null; + metadata: any; content: { - type: 'video' - id: number - title: string - thumbnail: string - description: string - markAsCompleted: boolean - } + type: 'video'; + id: number; + title: string; + thumbnail: string; + description: string; + markAsCompleted: boolean; + }; }) => { const [contentCompleted, setContentCompleted] = useState( content.markAsCompleted, @@ -77,7 +77,10 @@ export const ContentRendererClient = ({ const handleMarkCompleted = async () => { setLoadingMarkAs(true); - const data: any = await handleMarkAsCompleted(!contentCompleted, content.id); + const data: any = await handleMarkAsCompleted( + !contentCompleted, + content.id, + ); if (data.contentId) { setContentCompleted((prev) => !prev); diff --git a/src/components/admin/CourseContent.tsx b/src/components/admin/CourseContent.tsx index dab84088b..443d32124 100644 --- a/src/components/admin/CourseContent.tsx +++ b/src/components/admin/CourseContent.tsx @@ -6,12 +6,12 @@ export const AdminCourseContent = ({ courseContent, courseId, }: { - courseId: number + courseId: number; courseContent: { - title: string - image: string - id: number - }[] + title: string; + image: string; + id: number; + }[]; }) => { const router = useRouter(); diff --git a/src/components/analytics/GoogleAnalytics.tsx b/src/components/analytics/GoogleAnalytics.tsx index fe8ea4745..6dc48a298 100644 --- a/src/components/analytics/GoogleAnalytics.tsx +++ b/src/components/analytics/GoogleAnalytics.tsx @@ -18,7 +18,7 @@ export const GoogleAnalytics = () => { function gtag() { // @ts-ignore // eslint-disable-next-line - dataLayer.push(arguments) + dataLayer.push(arguments); } //@ts-ignore diff --git a/src/components/comment/CommentDeleteForm.tsx b/src/components/comment/CommentDeleteForm.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/src/components/comment/CommentInputForm.tsx b/src/components/comment/CommentInputForm.tsx new file mode 100644 index 000000000..5e3fb5999 --- /dev/null +++ b/src/components/comment/CommentInputForm.tsx @@ -0,0 +1,57 @@ +'use client'; +import React from 'react'; +import { Button } from '../ui/button'; +import { useAction } from '@/hooks/useAction'; +import { createMessage } from '@/actions/comment'; +import { toast } from 'sonner'; +import { FormErrors } from '../FormError'; +import { usePathname } from 'next/navigation'; + +const CommentInputForm = ({ + contentId, + parentId = undefined, +}: { + contentId: number; + parentId?: number | undefined; +}) => { + const currentPath = usePathname(); + const formRef = React.useRef(null); + const { execute, fieldErrors } = useAction(createMessage, { + onSuccess: () => { + toast('Comment added'); + formRef.current?.reset(); + }, + onError: (error) => { + toast.error(error); + }, + }); + const handleFormSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.target as HTMLFormElement); + + const content = formData.get('content') as string; + + execute({ + content, + contentId, + parentId, + currentPath, + }); + }; + return ( +
+