diff --git a/.gitignore b/.gitignore index 44b01e3..e95cfa0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules server/.env +.env diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1 @@ +{} diff --git a/admin-frontend/.gitignore b/admin-frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/admin-frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/admin-frontend/README.md b/admin-frontend/README.md new file mode 100644 index 0000000..7059a96 --- /dev/null +++ b/admin-frontend/README.md @@ -0,0 +1,12 @@ +# React + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. diff --git a/admin-frontend/eslint.config.js b/admin-frontend/eslint.config.js new file mode 100644 index 0000000..cee1e2c --- /dev/null +++ b/admin-frontend/eslint.config.js @@ -0,0 +1,29 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs['recommended-latest'], + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + rules: { + 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + }, + }, +]) diff --git a/admin-frontend/index.html b/admin-frontend/index.html new file mode 100644 index 0000000..aedc9bb --- /dev/null +++ b/admin-frontend/index.html @@ -0,0 +1,20 @@ + + + + + + + + + + + CS Central Admin Portal + + +
+ + + diff --git a/admin-frontend/package-lock.json b/admin-frontend/package-lock.json new file mode 100644 index 0000000..4816dc9 --- /dev/null +++ b/admin-frontend/package-lock.json @@ -0,0 +1,3439 @@ +{ + "name": "admin-frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "admin-frontend", + "version": "0.0.0", + "dependencies": { + "bootstrap": "^5.3.2", + "dompurify": "^3.2.6", + "html-react-parser": "^5.2.5", + "react": "^19.1.0", + "react-bootstrap": "^2.9.1", + "react-bootstrap-icons": "^1.10.3", + "react-bootstrap-typeahead": "^6.3.2", + "react-dom": "^19.1.0", + "react-hot-toast": "^2.5.2", + "react-router-dom": "^6.18.0" + }, + "devDependencies": { + "@eslint/js": "^9.29.0", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.5.2", + "eslint": "^9.29.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.2.0", + "vite": "^7.0.0" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", + "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.6", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", + "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.0", + "@babel/types": "^7.28.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", + "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", + "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", + "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", + "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", + "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz", + "integrity": "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", + "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", + "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", + "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", + "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", + "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", + "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", + "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", + "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", + "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", + "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", + "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", + "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", + "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", + "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", + "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", + "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", + "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", + "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", + "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", + "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", + "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", + "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", + "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", + "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", + "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", + "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", + "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.30.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.1.tgz", + "integrity": "sha512-zXhuECFlyep42KZUhWjfvsmXGX39W8K8LFb8AWXM9gSV9dQB+MrJGLKvW6Zw0Ggnbpw0VHTtrhFXYe3Gym18jg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz", + "integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", + "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.2.tgz", + "integrity": "sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.2.tgz", + "integrity": "sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.2", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.4.tgz", + "integrity": "sha512-JbbpPhp38UmXDDAu60RJmbeme37Jbgsm7NrHGgzYYFKmblzRUh6Pa641dII6LsjwF4XlScDrde2UAzDo/b9KPw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.2" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@react-aria/ssr": { + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.9.tgz", + "integrity": "sha512-2P5thfjfPy/np18e5wD4WPt8ydNXhij1jwA8oehxZTFqlgVMGXzcWKxTb4RtJrLFsqPO7RUQTiY8QJk0M4Vy2g==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", + "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@restart/hooks": { + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz", + "integrity": "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@restart/ui": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@restart/ui/-/ui-1.9.4.tgz", + "integrity": "sha512-N4C7haUc3vn4LTwVUPlkJN8Ach/+yIMvRuTVIhjilNHqegY60SGLrzud6errOMNJwSnmYFnt1J0H/k8FE3A4KA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@popperjs/core": "^2.11.8", + "@react-aria/ssr": "^3.5.0", + "@restart/hooks": "^0.5.0", + "@types/warning": "^3.0.3", + "dequal": "^2.0.3", + "dom-helpers": "^5.2.0", + "uncontrollable": "^8.0.4", + "warning": "^4.0.3" + }, + "peerDependencies": { + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + } + }, + "node_modules/@restart/ui/node_modules/@restart/hooks": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.5.1.tgz", + "integrity": "sha512-EMoH04NHS1pbn07iLTjIjgttuqb7qu4+/EyhAx27MHpoENcB2ZdSsLTNxmKD+WEPnZigo62Qc8zjGnNxoSE/5Q==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@restart/ui/node_modules/uncontrollable": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-8.0.4.tgz", + "integrity": "sha512-ulRWYWHvscPFc0QQXvyJjY6LIXU56f0h8pQFvhxiKk5V1fcI8gp9Ht9leVAhrVjzqMw0BgjspBINx9r6oyJUvQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.14.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.19", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.19.tgz", + "integrity": "sha512-3FL3mnMbPu0muGOCaKAhhFEYmqv9eTfPSJRJmANrCwtgK8VuxpsZDGK+m0LYAGoyO8+0j5uRe4PeyPDK1yA/hA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.1.tgz", + "integrity": "sha512-JAcBr1+fgqx20m7Fwe1DxPUl/hPkee6jA6Pl7n1v2EFiktAHenTaXl5aIFjUIEsfn9w3HE4gK1lEgNGMzBDs1w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.1.tgz", + "integrity": "sha512-RurZetXqTu4p+G0ChbnkwBuAtwAbIwJkycw1n6GvlGlBuS4u5qlr5opix8cBAYFJgaY05TWtM+LaoFggUmbZEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.1.tgz", + "integrity": "sha512-fM/xPesi7g2M7chk37LOnmnSTHLG/v2ggWqKj3CCA1rMA4mm5KVBT1fNoswbo1JhPuNNZrVwpTvlCVggv8A2zg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.1.tgz", + "integrity": "sha512-gDnWk57urJrkrHQ2WVx9TSVTH7lSlU7E3AFqiko+bgjlh78aJ88/3nycMax52VIVjIm3ObXnDL2H00e/xzoipw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.1.tgz", + "integrity": "sha512-wnFQmJ/zPThM5zEGcnDcCJeYJgtSLjh1d//WuHzhf6zT3Md1BvvhJnWoy+HECKu2bMxaIcfWiu3bJgx6z4g2XA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.1.tgz", + "integrity": "sha512-uBmIxoJ4493YATvU2c0upGz87f99e3wop7TJgOA/bXMFd2SvKCI7xkxY/5k50bv7J6dw1SXT4MQBQSLn8Bb/Uw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.1.tgz", + "integrity": "sha512-n0edDmSHlXFhrlmTK7XBuwKlG5MbS7yleS1cQ9nn4kIeW+dJH+ExqNgQ0RrFRew8Y+0V/x6C5IjsHrJmiHtkxQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.1.tgz", + "integrity": "sha512-8WVUPy3FtAsKSpyk21kV52HCxB+me6YkbkFHATzC2Yd3yuqHwy2lbFL4alJOLXKljoRw08Zk8/xEj89cLQ/4Nw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.1.tgz", + "integrity": "sha512-yuktAOaeOgorWDeFJggjuCkMGeITfqvPgkIXhDqsfKX8J3jGyxdDZgBV/2kj/2DyPaLiX6bPdjJDTu9RB8lUPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.1.tgz", + "integrity": "sha512-W+GBM4ifET1Plw8pdVaecwUgxmiH23CfAUj32u8knq0JPFyK4weRy6H7ooxYFD19YxBulL0Ktsflg5XS7+7u9g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.1.tgz", + "integrity": "sha512-1zqnUEMWp9WrGVuVak6jWTl4fEtrVKfZY7CvcBmUUpxAJ7WcSowPSAWIKa/0o5mBL/Ij50SIf9tuirGx63Ovew==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.1.tgz", + "integrity": "sha512-Rl3JKaRu0LHIx7ExBAAnf0JcOQetQffaw34T8vLlg9b1IhzcBgaIdnvEbbsZq9uZp3uAH+JkHd20Nwn0h9zPjA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.1.tgz", + "integrity": "sha512-j5akelU3snyL6K3N/iX7otLBIl347fGwmd95U5gS/7z6T4ftK288jKq3A5lcFKcx7wwzb5rgNvAg3ZbV4BqUSw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.1.tgz", + "integrity": "sha512-ppn5llVGgrZw7yxbIm8TTvtj1EoPgYUAbfw0uDjIOzzoqlZlZrLJ/KuiE7uf5EpTpCTrNt1EdtzF0naMm0wGYg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.1.tgz", + "integrity": "sha512-Hu6hEdix0oxtUma99jSP7xbvjkUM/ycke/AQQ4EC5g7jNRLLIwjcNwaUy95ZKBJJwg1ZowsclNnjYqzN4zwkAw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.1.tgz", + "integrity": "sha512-EtnsrmZGomz9WxK1bR5079zee3+7a+AdFlghyd6VbAjgRJDbTANJ9dcPIPAi76uG05micpEL+gPGmAKYTschQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.1.tgz", + "integrity": "sha512-iAS4p+J1az6Usn0f8xhgL4PaU878KEtutP4hqw52I4IO6AGoyOkHCxcc4bqufv1tQLdDWFx8lR9YlwxKuv3/3g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.1.tgz", + "integrity": "sha512-NtSJVKcXwcqozOl+FwI41OH3OApDyLk3kqTJgx8+gp6On9ZEt5mYhIsKNPGuaZr3p9T6NWPKGU/03Vw4CNU9qg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.1.tgz", + "integrity": "sha512-JYA3qvCOLXSsnTR3oiyGws1Dm0YTuxAAeaYGVlGpUsHqloPcFjPg+X0Fj2qODGLNwQOAcCiQmHub/V007kiH5A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.1.tgz", + "integrity": "sha512-J8o22LuF0kTe7m+8PvW9wk3/bRq5+mRo5Dqo6+vXb7otCm3TPhYOJqOaQtGU9YMWQSL3krMnoOxMr0+9E6F3Ug==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@swc/helpers": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", + "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", + "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.1.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", + "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.1.6", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz", + "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/warning": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz", + "integrity": "sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.6.0.tgz", + "integrity": "sha512-5Kgff+m8e2PB+9j51eGHEpn5kUzRKH2Ry0qGoe8ItJg7pqnkPrYPkDQZGgGmTa0EGarHrkjLvOdU3b1fzI8otQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.19", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/bootstrap": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.7.tgz", + "integrity": "sha512-7KgiD8UHjfcPBHEpDNg+zGz8L3LqR3GVwqZiBRFX04a1BCArZOz1r2kjly2HQ0WokqTO0v1nF+QAt8dsW4lKlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.25.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001726", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001726.tgz", + "integrity": "sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/dompurify": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz", + "integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.178", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.178.tgz", + "integrity": "sha512-wObbz/ar3Bc6e4X5vf0iO8xTN8YAjN/tgiAOJLr7yjYFtP9wAjq8Mb5h0yn6kResir+VYx2DXBj9NNobs0ETSA==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", + "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.5", + "@esbuild/android-arm": "0.25.5", + "@esbuild/android-arm64": "0.25.5", + "@esbuild/android-x64": "0.25.5", + "@esbuild/darwin-arm64": "0.25.5", + "@esbuild/darwin-x64": "0.25.5", + "@esbuild/freebsd-arm64": "0.25.5", + "@esbuild/freebsd-x64": "0.25.5", + "@esbuild/linux-arm": "0.25.5", + "@esbuild/linux-arm64": "0.25.5", + "@esbuild/linux-ia32": "0.25.5", + "@esbuild/linux-loong64": "0.25.5", + "@esbuild/linux-mips64el": "0.25.5", + "@esbuild/linux-ppc64": "0.25.5", + "@esbuild/linux-riscv64": "0.25.5", + "@esbuild/linux-s390x": "0.25.5", + "@esbuild/linux-x64": "0.25.5", + "@esbuild/netbsd-arm64": "0.25.5", + "@esbuild/netbsd-x64": "0.25.5", + "@esbuild/openbsd-arm64": "0.25.5", + "@esbuild/openbsd-x64": "0.25.5", + "@esbuild/sunos-x64": "0.25.5", + "@esbuild/win32-arm64": "0.25.5", + "@esbuild/win32-ia32": "0.25.5", + "@esbuild/win32-x64": "0.25.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.30.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.30.1.tgz", + "integrity": "sha512-zmxXPNMOXmwm9E0yQLi5uqXHs7uq2UIiqEKo3Gq+3fwo1XrJ+hijAZImyF7hclW3E6oHz43Yk3RP8at6OTKflQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.0", + "@eslint/core": "^0.14.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.30.1", + "@eslint/plugin-kit": "^0.3.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.20", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz", + "integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz", + "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/goober": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz", + "integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-dom-parser": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/html-dom-parser/-/html-dom-parser-5.1.1.tgz", + "integrity": "sha512-+o4Y4Z0CLuyemeccvGN4bAO20aauB2N9tFEAep5x4OW34kV4PTarBHm6RL02afYt2BMKcr0D2Agep8S3nJPIBg==", + "license": "MIT", + "dependencies": { + "domhandler": "5.0.3", + "htmlparser2": "10.0.0" + } + }, + "node_modules/html-react-parser": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/html-react-parser/-/html-react-parser-5.2.5.tgz", + "integrity": "sha512-bRPdv8KTqG9CEQPMNGksDqmbiRfVQeOidry8pVetdh/1jQ1Edx4KX5m0lWvDD89Pt4CqTYjK1BLz6NoNVxN/Uw==", + "license": "MIT", + "dependencies": { + "domhandler": "5.0.3", + "html-dom-parser": "5.1.1", + "react-property": "2.0.2", + "style-to-js": "1.1.16" + }, + "peerDependencies": { + "@types/react": "0.14 || 15 || 16 || 17 || 18 || 19", + "react": "0.14 || 15 || 16 || 17 || 18 || 19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/htmlparser2": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", + "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.1", + "entities": "^6.0.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", + "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==", + "license": "MIT" + }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types-extra": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz", + "integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==", + "license": "MIT", + "dependencies": { + "react-is": "^16.3.2", + "warning": "^4.0.0" + }, + "peerDependencies": { + "react": ">=0.14.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-bootstrap": { + "version": "2.10.10", + "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.10.tgz", + "integrity": "sha512-gMckKUqn8aK/vCnfwoBpBVFUGT9SVQxwsYrp9yDHt0arXMamxALerliKBxr1TPbntirK/HGrUAHYbAeQTa9GHQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@restart/hooks": "^0.4.9", + "@restart/ui": "^1.9.4", + "@types/prop-types": "^15.7.12", + "@types/react-transition-group": "^4.4.6", + "classnames": "^2.3.2", + "dom-helpers": "^5.2.1", + "invariant": "^2.2.4", + "prop-types": "^15.8.1", + "prop-types-extra": "^1.1.0", + "react-transition-group": "^4.4.5", + "uncontrollable": "^7.2.1", + "warning": "^4.0.3" + }, + "peerDependencies": { + "@types/react": ">=16.14.8", + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-bootstrap-icons": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/react-bootstrap-icons/-/react-bootstrap-icons-1.11.6.tgz", + "integrity": "sha512-ycXiyeSyzbS1C4+MlPTYe0riB+UlZ7LV7YZQYqlERV2cxDiKtntI0huHmP/3VVvzPt4tGxqK0K+Y6g7We3U6tQ==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "react": ">=16.8.6" + } + }, + "node_modules/react-bootstrap-typeahead": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/react-bootstrap-typeahead/-/react-bootstrap-typeahead-6.4.1.tgz", + "integrity": "sha512-Uw3vB4H5WGmnOHdQvqVKkOyzzxZ5NUrsd/HKhGnbta5+bEI99xyghr9Y0tVLcMoQZ9Wxpe4g/LJl75L/FsHq/A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.14.6", + "@floating-ui/react-dom": "^2.1.2", + "@restart/hooks": "^0.4.0", + "@restart/ui": "^1.9.4", + "classnames": "^2.2.0", + "fast-deep-equal": "^3.1.1", + "invariant": "^2.2.1", + "lodash.debounce": "^4.0.8", + "prop-types": "^15.5.8", + "scroll-into-view-if-needed": "^3.1.0", + "warning": "^4.0.1" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, + "node_modules/react-hot-toast": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.5.2.tgz", + "integrity": "sha512-Tun3BbCxzmXXM7C+NI4qiv6lT0uwGh4oAfeJyNOjYUejTsm35mK9iCaYLGv8cBz9L5YxZLx/2ii7zsIwPtPUdw==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", + "license": "MIT" + }, + "node_modules/react-property": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/react-property/-/react-property-2.0.2.tgz", + "integrity": "sha512-+PbtI3VuDV0l6CleQMsx2gtK0JZbZKbpdu5ynr+lbsuvtmgbNcS3VM0tuY2QjFNOcWxvXeHjDpy42RO+4U2rug==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz", + "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz", + "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.0", + "react-router": "6.30.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.1.tgz", + "integrity": "sha512-x8H8aPvD+xbl0Do8oez5f5o8eMS3trfCghc4HhLAnCkj7Vl0d1JWGs0UF/D886zLW2rOj2QymV/JcSSsw+XDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.44.1", + "@rollup/rollup-android-arm64": "4.44.1", + "@rollup/rollup-darwin-arm64": "4.44.1", + "@rollup/rollup-darwin-x64": "4.44.1", + "@rollup/rollup-freebsd-arm64": "4.44.1", + "@rollup/rollup-freebsd-x64": "4.44.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.44.1", + "@rollup/rollup-linux-arm-musleabihf": "4.44.1", + "@rollup/rollup-linux-arm64-gnu": "4.44.1", + "@rollup/rollup-linux-arm64-musl": "4.44.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.44.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.44.1", + "@rollup/rollup-linux-riscv64-gnu": "4.44.1", + "@rollup/rollup-linux-riscv64-musl": "4.44.1", + "@rollup/rollup-linux-s390x-gnu": "4.44.1", + "@rollup/rollup-linux-x64-gnu": "4.44.1", + "@rollup/rollup-linux-x64-musl": "4.44.1", + "@rollup/rollup-win32-arm64-msvc": "4.44.1", + "@rollup/rollup-win32-ia32-msvc": "4.44.1", + "@rollup/rollup-win32-x64-msvc": "4.44.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-to-js": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.16.tgz", + "integrity": "sha512-/Q6ld50hKYPH3d/r6nr117TZkHR0w0kGGIVfpG9N6D8NymRPM9RqCUv4pRpJ62E5DqOYx2AFpbZMyCPnjQCnOw==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.8" + } + }, + "node_modules/style-to-object": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.8.tgz", + "integrity": "sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.4" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/uncontrollable": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", + "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.6.3", + "@types/react": ">=16.9.11", + "invariant": "^2.2.4", + "react-lifecycles-compat": "^3.0.4" + }, + "peerDependencies": { + "react": ">=15.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.0.tgz", + "integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.6", + "picomatch": "^4.0.2", + "postcss": "^8.5.6", + "rollup": "^4.40.0", + "tinyglobby": "^0.2.14" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/admin-frontend/package.json b/admin-frontend/package.json new file mode 100644 index 0000000..805f10c --- /dev/null +++ b/admin-frontend/package.json @@ -0,0 +1,34 @@ +{ + "name": "admin-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "start": "vite", + "build": "vite build", + "serve": "vite preview" + }, + "dependencies": { + "react": "^19.1.0", + "react-dom": "^19.1.0", + "bootstrap": "^5.3.2", + "dompurify": "^3.2.6", + "html-react-parser": "^5.2.5", + "react-bootstrap": "^2.9.1", + "react-bootstrap-icons": "^1.10.3", + "react-bootstrap-typeahead": "^6.3.2", + "react-hot-toast": "^2.5.2", + "react-router-dom": "^6.18.0" + }, + "devDependencies": { + "@eslint/js": "^9.29.0", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.5.2", + "eslint": "^9.29.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.2.0", + "vite": "^7.0.0" + } +} diff --git a/admin-frontend/public/cc_logo_white.png b/admin-frontend/public/cc_logo_white.png new file mode 100644 index 0000000..4e6cd51 Binary files /dev/null and b/admin-frontend/public/cc_logo_white.png differ diff --git a/admin-frontend/public/default_avatar.jpg b/admin-frontend/public/default_avatar.jpg new file mode 100644 index 0000000..3fe2058 Binary files /dev/null and b/admin-frontend/public/default_avatar.jpg differ diff --git a/admin-frontend/public/logo_white.png b/admin-frontend/public/logo_white.png new file mode 100644 index 0000000..ba75f6e Binary files /dev/null and b/admin-frontend/public/logo_white.png differ diff --git a/admin-frontend/public/vite.svg b/admin-frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/admin-frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin-frontend/src/App.jsx b/admin-frontend/src/App.jsx new file mode 100644 index 0000000..00d5501 --- /dev/null +++ b/admin-frontend/src/App.jsx @@ -0,0 +1,48 @@ +import { Route, Routes, Navigate, Outlet } from "react-router-dom"; +import { Toaster } from "react-hot-toast"; +import BasePage from "./pages/BasePage"; +import Home from "./pages/HomePage/Home"; +import ArticleView from "./pages/ArticlesPage/ArticleView"; +import SigninPage from "./pages/SignInPage/SignInPage"; +import { useAuthContext } from "./hooks/useAuthContext"; +import LoggedOutHomePage from "./pages/LoggedOutHome/LoggedOutHomePage"; +import LoadingSpinner from "./Components/LoadingSpinner/LoadingSpinner"; +import { useEffect } from "react"; +import { useLoadingSpinner } from "./context/SpinnerContext"; +import UserDataPage from "./pages/UsersPage/UserDataPage"; +import ArticlesPage from "./pages/ArticlesPage/ArticlesPage"; +import UsersPage from "./pages/UsersPage/UsersPage"; +function App() { + const { admin } = useAuthContext(); + const { spinnerIsShowing } = useLoadingSpinner(); + + const ProtectedRoute = () => { + const { admin, isAuthChecked } = useAuthContext(); + + if (!isAuthChecked) return null; + if (!admin) return ; + + return ; + }; + + return ( + <> + + }> + : } /> + {!admin && } />} + }> + } /> + } /> + } /> + } /> + + + + + {spinnerIsShowing && } + + ); +} + +export default App; diff --git a/admin-frontend/src/Components/ArrowMarker/ArrowMarker.jsx b/admin-frontend/src/Components/ArrowMarker/ArrowMarker.jsx new file mode 100644 index 0000000..cc6f9a0 --- /dev/null +++ b/admin-frontend/src/Components/ArrowMarker/ArrowMarker.jsx @@ -0,0 +1,12 @@ +import "./ArrowMarker.scss"; + +//IMPORTANT: will expand to fit container, so size this using container +export default function ArrowMarker() { + return ( +
+
+
+
+
+ ); +} diff --git a/admin-frontend/src/Components/ArrowMarker/ArrowMarker.scss b/admin-frontend/src/Components/ArrowMarker/ArrowMarker.scss new file mode 100644 index 0000000..f79133f --- /dev/null +++ b/admin-frontend/src/Components/ArrowMarker/ArrowMarker.scss @@ -0,0 +1,44 @@ +@import "bootstrap/scss/bootstrap-grid.scss"; +@import "bootstrap/scss/grid"; +@import "bootstrap/scss/variables"; +@import "bootstrap/scss/mixins"; +@import "../../scss/variables"; + +.arrow-marker { + width: 100%; + height: 100%; + display: inline-flex; +} + +//rectangle point +.arrow-marker-base { + height: 100%; + width: 16%; +} + +//gap between parts +.arrow-marker-gap { + width: 16%; +} + +//pointy part +.arrow-marker-point { + height: 100%; + width: 68%; + clip-path: polygon(0 0, 0 100%, 50% 100%, 100% 50%, 50% 0%); +} + +//COLOR MODES +@include color-mode(light) { + .arrow-marker-base, + .arrow-marker-point { + background: $arrow-marker-light; + } +} + +@include color-mode(dark) { + .arrow-marker-base, + .arrow-marker-point { + background: $arrow-marker-dark; + } +} diff --git a/admin-frontend/src/Components/Article/Article.jsx b/admin-frontend/src/Components/Article/Article.jsx new file mode 100644 index 0000000..1bace76 --- /dev/null +++ b/admin-frontend/src/Components/Article/Article.jsx @@ -0,0 +1,101 @@ +import ArticleImage from "./ArticleImage.jsx"; +import ArticleHeader from "./ArticleHeader/ArticleHeader.jsx"; +import TableOfContents from "./Section/TableOfContents.jsx"; +import RelatedTopicsList from "./Topics/RelatedTopicsList.jsx"; +import BodySection from "./Section/BodySection.jsx"; +import { Container, Row, Col, Stack } from "react-bootstrap"; +import "./ArticleComponents.scss"; +import "./Article.scss"; +import { useEffect, useState } from "react"; + +//this component accepts an article object and displays the corresponding article +export default function Article({ article }) { + //extracts article data pieces from provided article + + let titleBlocks = article.header.blocks; + let descriptionBlocks = article.description.blocks; + + const [articleData, setArticleData] = useState({ + ...article, + isBookmarked: false, + }); + + let contentSequence = []; + + for (const sectionIndex in articleData.articleBody) { + const section = articleData.articleBody[sectionIndex]; + if (section.blocks.length > 0 && section.blocks[0].type === "header") { + contentSequence.push({ + heading: section.blocks[0], + link: `#${section.id}`, + }); + } else { + contentSequence.push({ + heading: { + type: "header", + data: { text: `Section ${Number(sectionIndex) + 1}` }, + }, + link: `#${section.id}`, + }); + } + } + + return ( + <> + + + + + + + + + + + + + + + + + + + {articleData.articleBody.map((bodySection, index) => { + //assumes that if there is a title, it will be the first block + + let currentBodySectionBlocks = [...bodySection.blocks]; + + //if the first block isn't a header, it will insert a dummy header + if ( + bodySection.blocks.length > 0 && + bodySection.blocks[0].type !== "header" + ) { + currentBodySectionBlocks.splice(0, 0, { + type: "header", + data: { text: `Section ${index + 1}`, level: 2 }, + }); + } + return ( + + ); + })} + + + + + + ); +} diff --git a/admin-frontend/src/Components/Article/Article.scss b/admin-frontend/src/Components/Article/Article.scss new file mode 100644 index 0000000..1f5b0fc --- /dev/null +++ b/admin-frontend/src/Components/Article/Article.scss @@ -0,0 +1,17 @@ +@import "../../../node_modules/bootstrap/scss/bootstrap-grid.scss"; +@import "../../../node_modules/bootstrap/scss/grid"; +@import "../../../node_modules/bootstrap/scss/variables"; +@import "../../../node_modules//bootstrap/scss/mixins"; +@import "../../scss/variables"; +.preview-container { + max-width: 1000px; + padding-bottom: 30px; + + .article-image { + max-width: 100%; + width: 1000px; + height: auto; + max-height: 500px; + object-fit: cover; + } +} diff --git a/admin-frontend/src/Components/Article/ArticleComponents.scss b/admin-frontend/src/Components/Article/ArticleComponents.scss new file mode 100644 index 0000000..ccb1b74 --- /dev/null +++ b/admin-frontend/src/Components/Article/ArticleComponents.scss @@ -0,0 +1,286 @@ +@import "../../scss/variables"; +@import "bootstrap/scss/bootstrap-grid.scss"; +@import "bootstrap/scss/grid"; +@import "bootstrap/scss/variables"; +@import "bootstrap/scss/mixins"; +@import "../../scss/variables"; +//SectionHeader, ArticleHeaderDesc marker styling +.section-header-marker, +.article-desc-marker { + display: inline-block; + width: 0.7rem; +} +.section-header { + width: calc(100% - 0.7rem); + margin: 0 !important; + line-height: 100%; + display: flex; + align-items: center; +} + +//ArticleHeaderDesc +.article-desc { + font-weight: 700; + margin: 0; + font-size: calc(1rem + 0.1vw); +} + +.article-desc-arrow-marker-container { + height: 40px; + width: 20px; + flex-shrink: 0; +} + +//ArticleHeaderAuthorDate +.article-header-author-date { + font-size: calc(0.75rem + 0.1vw); +} + +.article-header-author-date, +.article-desc { + @media screen and (max-width: 800px) { + font-size: calc(0.625rem + 0.3vw); + } + + @media screen and (max-width: 500px) { + font-size: calc(0.6rem + 0.3vw); + } +} + +.article-bookmark { + font-size: calc(1rem + 0.5vw + 0.5vh); + + &:hover { + cursor: pointer; + } + + &:active { + cursor: auto; + } +} +//Table of Contents +.table-content-heading { + font-weight: 600; + margin: 0; +} + +.table-contents-marker-container { + position: absolute; + display: flex; + justify-content: flex-start; + width: 20px; + height: 100%; + padding-left: 0; + flex-shrink: 0; + left: 0; +} + +.table-contents-marker { + display: inline-block; + width: 2px; + margin-left: 32%; + flex-shrink: 0; +} + +//Article Body Section highlight styling +@keyframes article-highlight { + 0% { + background-color: inherit; + } + + 50% { + background-color: $primary; + } + + 100% { + background-color: inherit; + } +} +.article-body-section:target { + animation: article-highlight 700ms 1; +} + +//SectionContent +.section-content-marker-container { + display: flex; + width: 0.7rem; + justify-content: center; + align-items: stretch; +} + +.section-content-marker { + display: inline-block; + width: 2px; +} + +.section-content-text, +.table-content-heading { + width: calc(100% - 0.7rem); + font-size: calc(1rem + 0.1vw); + + @media screen and (max-width: 800px) { + font-size: calc(0.65rem + 0.3vw); + } + + @media screen and (max-width: 500px) { + font-size: calc(0.7rem + 0.3vw); + } +} + +//SectionContent + +//32% gap + +//article arrow marker container and section content marker container have to be same width +//in order for section content marker to be aligned with arrow properly there has to be a gap to the left +//of divider that is 32% of width + +.article-section-arrow-marker-container { + height: 40px; + width: 20px; + flex-shrink: 0; +} + +.section-content-marker-container { + position: absolute; + display: flex; + justify-content: flex-start; + width: 20px; + height: 100%; + padding-left: 0; + flex-shrink: 0; + top: -1px; + left: 0; +} + +.section-content-marker { + display: inline-block; + width: 2px; + margin-left: 32%; + flex-shrink: 0; +} + +.section-content-text { + margin: 0; + margin-bottom: 0.2rem; + width: calc(100% - 0.7rem); + font-size: calc(1rem + 0.1vw); + + @media screen and (max-width: 800px) { + font-size: calc(0.65rem + 0.3vw); + } + + @media screen and (max-width: 500px) { + font-size: calc(0.7rem + 0.3vw); + } + + &:hover { + cursor: pointer; + } +} + +//Related Topics List +.related-topics-heading { + font-size: calc(0.975rem + 0.1vw); + line-height: 2rem; + font-weight: 600; +} + +//Topics List +.topic-list-title, +.topic-link { + font-size: calc(0.65rem + 0.1vw); + font-weight: 600; +} + +.topic-link { + text-decoration: none; +} + +.topic-link:hover { + filter: brightness(0.8); +} +.topic-link:active { + filter: brightness(0.6); + text-decoration: underline; +} + +//Article Image +.article-image { + width: 100%; + object-fit: cover; + height: 25vh; + max-height: 400px; + min-height: 300px; + + &:hover { + cursor: pointer; + } + + @media screen and (max-width: 800px) { + min-height: 200px; + max-height: 300px; + } + @media screen and (max-width: 500px) { + min-height: 100px; + max-height: 150px; + } +} + +/*COLOR MODES*/ +@include color-mode(light) { + .section-content-marker, + .table-contents-marker { + background: $arrow-marker-light; + } + + .article-title { + color: $article-header-light; + } + + .related-topics-heading, + .topic-list-title, + .section-header, + .section-content-text { + color: $primary-text-light; + } + + .topic-link { + color: $related-topic-link-light; + } + .table-content-heading, + .article-desc { + color: $article-toc-desc-light; + } + .article-bookmark { + color: $bookmark-bg-light; + } +} + +@include color-mode(dark) { + .section-content-marker, + .table-contents-marker { + background: $arrow-marker-dark; + } + + .article-title { + color: $article-header-dark; + } + + .related-topics-heading, + .topic-list-title, + .section-header, + .section-content-text { + color: $primary-text-dark; + } + .topic-link { + color: $related-topic-link-dark; + } + .table-content-heading, + .article-desc { + color: $article-toc-desc-dark; + } + .article-bookmark { + color: $bookmark-bg-dark; + } +} diff --git a/admin-frontend/src/Components/Article/ArticleHeader/ArticleHeader.jsx b/admin-frontend/src/Components/Article/ArticleHeader/ArticleHeader.jsx new file mode 100644 index 0000000..0b29b50 --- /dev/null +++ b/admin-frontend/src/Components/Article/ArticleHeader/ArticleHeader.jsx @@ -0,0 +1,59 @@ +import { Stack } from "react-bootstrap"; +import ArticleHeaderDesc from "./ArticleHeaderDesc"; +import ArticleHeaderTitle from "./ArticleHeaderTitle"; +import ArticleHeaderAuthorDate from "./ArticleHeaderAuthorDate"; +import { Bookmark, BookmarkFill } from "react-bootstrap-icons"; + +//Component for the article header +/* +takes in: + + +json block containing article title, +json block containing article description, +string of author's name, +string containing date of creation +(for now this is simply normal data like "Oct 9, 2023", + but if we use a postgres time date it will need to be converted) + + //and state for whether article is bookmarked and toggler for that bookmark +*/ + +export default function ArticleHeader({ + titleBlocks, + descriptionBlocks, + author, + date, + isBookmarked, + bookmarkToggler, + disableBookmark, +}) { + return ( + + + +
+ + +
+ {!disableBookmark && + (isBookmarked ? ( + + ) : ( + + ))} +
+
+
+ ); +} diff --git a/admin-frontend/src/Components/Article/ArticleHeader/ArticleHeaderAuthorDate.jsx b/admin-frontend/src/Components/Article/ArticleHeader/ArticleHeaderAuthorDate.jsx new file mode 100644 index 0000000..3d51e3e --- /dev/null +++ b/admin-frontend/src/Components/Article/ArticleHeader/ArticleHeaderAuthorDate.jsx @@ -0,0 +1,11 @@ +//component for article author and date +export default function ArticleHeaderAuthorDate({ author, date }) { + return ( +
+ By {author} +
+ {date ? `Published ${date}` : `Unpublished`} +
{" "} +
+ ); +} diff --git a/admin-frontend/src/Components/Article/ArticleHeader/ArticleHeaderDesc.jsx b/admin-frontend/src/Components/Article/ArticleHeader/ArticleHeaderDesc.jsx new file mode 100644 index 0000000..4965a95 --- /dev/null +++ b/admin-frontend/src/Components/Article/ArticleHeader/ArticleHeaderDesc.jsx @@ -0,0 +1,18 @@ +import { Stack } from "react-bootstrap"; +import ArrowMarker from "../../ArrowMarker/ArrowMarker"; +import BlocksParser from "../../BlocksParser/BlocksParser"; +//component for author description +export default function ArticleHeaderDesc({ descriptionBlocks }) { + return ( +
+ +
+ +
+

+ +

+
+
+ ); +} diff --git a/admin-frontend/src/Components/Article/ArticleHeader/ArticleHeaderTitle.jsx b/admin-frontend/src/Components/Article/ArticleHeader/ArticleHeaderTitle.jsx new file mode 100644 index 0000000..4233d5c --- /dev/null +++ b/admin-frontend/src/Components/Article/ArticleHeader/ArticleHeaderTitle.jsx @@ -0,0 +1,30 @@ +import { Stack } from "react-bootstrap"; + +import { Bookmark, BookmarkFill } from "react-bootstrap-icons"; +import BlocksParser from "../../BlocksParser/BlocksParser"; + +//component for article title +export default function ArticleHeaderTitle({ + titleBlocks, + isBookmarked, + bookmarkToggler, + disableBookmark, +}) { + return ( + + {!disableBookmark && + (isBookmarked ? ( + + ) : ( + + ))} + +

+ +

+
+ ); +} diff --git a/admin-frontend/src/Components/Article/ArticleImage.jsx b/admin-frontend/src/Components/Article/ArticleImage.jsx new file mode 100644 index 0000000..7619851 --- /dev/null +++ b/admin-frontend/src/Components/Article/ArticleImage.jsx @@ -0,0 +1,19 @@ +import { useState } from "react"; +import { Image } from "react-bootstrap"; +import ArticleImagePreview from "../../Components/Article/ArticleImagePreview/ArticleImagePreview"; +//Article Image +export default function ArticleImage({ image, alt_text }) { + const [show, setShow] = useState(false); + return ( + <> + + {alt_text} setShow(true)} + /> + + ); +} diff --git a/admin-frontend/src/Components/Article/ArticleImagePreview/ArticleImagePreview.jsx b/admin-frontend/src/Components/Article/ArticleImagePreview/ArticleImagePreview.jsx new file mode 100644 index 0000000..0203555 --- /dev/null +++ b/admin-frontend/src/Components/Article/ArticleImagePreview/ArticleImagePreview.jsx @@ -0,0 +1,26 @@ +import { Modal, Image } from "react-bootstrap"; +import "./ArticleImagePreview.scss"; + +export default function ArticleImagePreview({ + imageSrc, + show, + setShow, + caption, +}) { + return ( + setShow(false)} + className="preview-image-modal" + centered + > + + Image Preview + + + + + {caption && {caption}} + + ); +} diff --git a/admin-frontend/src/Components/Article/ArticleImagePreview/ArticleImagePreview.scss b/admin-frontend/src/Components/Article/ArticleImagePreview/ArticleImagePreview.scss new file mode 100644 index 0000000..c33494d --- /dev/null +++ b/admin-frontend/src/Components/Article/ArticleImagePreview/ArticleImagePreview.scss @@ -0,0 +1,16 @@ +.preview-image-modal { + .modal-dialog { + width: auto; + max-width: none; + } + + .modal-content { + width: fit-content; + height: fit-content; + margin: auto; + } + + .modal-footer { + justify-content: unset; + } +} diff --git a/admin-frontend/src/Components/Article/Section/BodySection.jsx b/admin-frontend/src/Components/Article/Section/BodySection.jsx new file mode 100644 index 0000000..bd4eba8 --- /dev/null +++ b/admin-frontend/src/Components/Article/Section/BodySection.jsx @@ -0,0 +1,25 @@ +import SectionHeader from "./SectionHeader"; +import SectionContent from "./SectionContent"; + +//Component for one of the sections within the body of an article +//takes in the body section blocks +//assumes that if there is a title, it will be the first block +export default function BodySection({ id, bodySectionBlocks }) { + let titleBlock = bodySectionBlocks[0]; + let bodyContentBlocks = [...bodySectionBlocks.slice(1)]; + + return ( +
+ + {bodyContentBlocks.map((block, index) => { + return ; + })} +
+ ); +} diff --git a/admin-frontend/src/Components/Article/Section/SectionContent.jsx b/admin-frontend/src/Components/Article/Section/SectionContent.jsx new file mode 100644 index 0000000..33242a7 --- /dev/null +++ b/admin-frontend/src/Components/Article/Section/SectionContent.jsx @@ -0,0 +1,14 @@ +import BlocksParser from "../../BlocksParser/BlocksParser"; +//Component for the body content of an article section's content +export default function SectionContent({ content }) { + return ( +
+
+
 
+
+ + + +
+ ); +} diff --git a/admin-frontend/src/Components/Article/Section/SectionHeader.jsx b/admin-frontend/src/Components/Article/Section/SectionHeader.jsx new file mode 100644 index 0000000..062cab1 --- /dev/null +++ b/admin-frontend/src/Components/Article/Section/SectionHeader.jsx @@ -0,0 +1,17 @@ +import ArrowMarker from "../../ArrowMarker/ArrowMarker"; +import BlocksParser from "../../BlocksParser/BlocksParser"; + +//Component for the title of a article section +export default function SectionHeader({ headerBlock }) { + return ( +
+ {/*
 
*/} +
+ +
+

+ +

+
+ ); +} diff --git a/admin-frontend/src/Components/Article/Section/TableOfContents.jsx b/admin-frontend/src/Components/Article/Section/TableOfContents.jsx new file mode 100644 index 0000000..c3bfbef --- /dev/null +++ b/admin-frontend/src/Components/Article/Section/TableOfContents.jsx @@ -0,0 +1,30 @@ +import { Stack } from "react-bootstrap"; +import SectionHeader from "./SectionHeader"; +import BlocksParser from "../../BlocksParser/BlocksParser"; + +/* +component for table of contents in article page, +takes in array of objects containing heading block data text and section id + +*/ +export default function TableOfContents({ contentSequence }) { + return ( +
+ +
+
 
+
+ + {contentSequence.map(({ heading, link }, index) => { + return ( + + {index + 1}. + + ); + })} + +
+ ); +} diff --git a/admin-frontend/src/Components/Article/Topics/RelatedTopicsList.jsx b/admin-frontend/src/Components/Article/Topics/RelatedTopicsList.jsx new file mode 100644 index 0000000..c5acb5d --- /dev/null +++ b/admin-frontend/src/Components/Article/Topics/RelatedTopicsList.jsx @@ -0,0 +1,30 @@ +//Component for list of related topics, which displays multiple categories of topic lists + +import { Stack } from "react-bootstrap"; +import TopicList from "./TopicList"; + +//accepts an array of objects, which each contain topic category name +//and an array of topics and their corresponding links +//these objects have the following format +/*{ + topicCategory: string, + topicList: Array of {topic: string, link: string} +} +*/ + +export default function RelatedTopicsList({ topicLists }) { + return ( + +

Related Topics

+ + {topicLists.map(({ topicCategory, topicList }) => ( + + ))} + +
+ ); +} diff --git a/admin-frontend/src/Components/Article/Topics/TopicList.jsx b/admin-frontend/src/Components/Article/Topics/TopicList.jsx new file mode 100644 index 0000000..177aac8 --- /dev/null +++ b/admin-frontend/src/Components/Article/Topics/TopicList.jsx @@ -0,0 +1,35 @@ +//displays a list of topics + +import { Stack } from "react-bootstrap"; +import { Link } from "react-router-dom"; + +//takes in a title for the list, +//and an array of objects that contain a topic and link +//the objects are of this format +/* +{ + topic: string, + link: string +} +*/ +export default function TopicList({ topicCategory, topicList }) { + return ( + + {/*Topic Title*/} + +

+ {topicCategory.toUpperCase()} +

+
+ + {/*Topic Link List */} + + {topicList.map(({ topic, link }) => ( + + {topic} + + ))} + +
+ ); +} diff --git a/admin-frontend/src/Components/BlocksParser/BlocksParser.jsx b/admin-frontend/src/Components/BlocksParser/BlocksParser.jsx new file mode 100644 index 0000000..5c79287 --- /dev/null +++ b/admin-frontend/src/Components/BlocksParser/BlocksParser.jsx @@ -0,0 +1,67 @@ +import parse from "html-react-parser"; +import DOMPurify from "dompurify"; +import { Figure } from "react-bootstrap"; +import { useState } from "react"; +import ArticleImagePreview from "../Article/ArticleImagePreview/ArticleImagePreview"; +//returns parsed article +//separating these into switch cases in case we need to have different logic for each type of block +export default function BlocksParser({ blocks }) { + const [show, setShow] = useState(false); + const [previewImage, setPreviewImage] = useState(null); + const [imageCaption, setImageCaption] = useState(null); + + return ( + <> + + + {blocks.map((block, index) => { + let processedBlock = null; + let processedHtmlString = null; + + switch (block.type) { + case "paragraph": + processedHtmlString = DOMPurify.sanitize(block.data.text); + processedBlock = parse(processedHtmlString); + break; + case "header": + processedHtmlString = DOMPurify.sanitize(block.data.text); + processedBlock = parse(processedHtmlString); + break; + case "image": + processedBlock = ( +
+ { + setPreviewImage(block.data.url); + setImageCaption(block.data.caption); + setShow(true); + }} + /> + {block.data.caption} +
+ ); + break; + case "list": + let listItemElements = block.data.items.map((item, index) => { + let itemProcessedHTMLString = DOMPurify.sanitize(item); + return ( +
  • {parse(itemProcessedHTMLString)}
  • + ); + }); + processedBlock =
      {listItemElements}
    ; + break; + default: + break; + } + + return processedBlock; + })} + + ); +} diff --git a/admin-frontend/src/Components/Footer/Footer.jsx b/admin-frontend/src/Components/Footer/Footer.jsx new file mode 100644 index 0000000..ec988ee --- /dev/null +++ b/admin-frontend/src/Components/Footer/Footer.jsx @@ -0,0 +1,82 @@ +import { Container, Stack, Row, Col, Image } from "react-bootstrap"; +import { Discord, Github, Instagram } from "react-bootstrap-icons"; +import "./Footer.scss"; +import logo from "../../assets/logo.png"; +export default function Footer() { + return ( + + ); +} diff --git a/admin-frontend/src/Components/Footer/Footer.scss b/admin-frontend/src/Components/Footer/Footer.scss new file mode 100644 index 0000000..cbe1c2a --- /dev/null +++ b/admin-frontend/src/Components/Footer/Footer.scss @@ -0,0 +1,143 @@ +@import "../../scss/variables.scss"; + +@import "/node_modules/bootstrap/scss/bootstrap-grid.scss"; +@import "/node_modules/bootstrap/scss/grid"; +@import "/node_modules//bootstrap/scss/mixins"; + +//colors +.footer { + background: $header-footer-bg; +} + +.footer * { + color: white; +} + +/*First half of footer*/ +.footer-logo { + @include media-breakpoint-down(md) { + height: 4.5rem; + } + + @include media-breakpoint-up(md) { + height: 6.5rem; + } +} + +.cc-logo-footer { + @include media-breakpoint-up(lg) { + padding-right: 5rem !important; + } +} + +/*Social Links*/ +.footer-socials-list { +} + +.footer-social-container > a { + width: fit-content; + align-items: center; + gap: 10px; +} +.footer-social-icon { + font-size: 2rem; +} +.footer-social-name { + font-size: 1rem; + font-weight: 200; + margin-left: 10px; +} +.footer-social-icon, +.footer-social-name { + line-height: 1.1rem; + vertical-align: middle; + display: inline-block; +} + +/*Second half*/ + +/*CS Catalog title*/ +.footer-title { + text-align: center; + font-size: 1.8rem; + font-weight: 300; + margin-left: 5rem; + > span:first-of-type { + font-family: "Fredericka The Great"; + font-size: 7rem; + font-weight: normal; + border-bottom: 1px solid white; + } +} + +.vertical-divider { + border-right: 1px solid white; + width: 1px; +} +/*more info links*/ + +.footer-more-info-links-container { + padding-left: 50px; + display: flex; + gap: 10px; +} + +.footer-more-info-link-container { + white-space: nowrap; + overflow: hidden; + width: 147px; +} + +.footer-socials-list, +.footer-links-container { + li { + list-style-type: none; + } +} + +//link hover/click styles +.social-link, +.more-info-link { + text-decoration: none; + margin-top: 40px; + &:hover { + font-weight: 700 !important; + cursor: pointer; + } + + &:active { + border-bottom: 1px solid white; + } +} + +//footer copyright +.footer-copyright { + font-style: italic; + font-weight: 200; + font-size: 0.9rem; + padding-top: 1rem; +} + +.footer-container { + width: 50rem !important; + @include media-breakpoint-down(md) { + width: 20rem !important; + } +} + +.links-container { + display: flex; + justify-content: space-between; + padding: 0 !important; + + @include media-breakpoint-down(lg) { + padding: 20px 0 !important; + justify-content: space-evenly; + } +} + +.social-icons-container { + display: flex; + flex-direction: column; + gap: 30px; +} diff --git a/admin-frontend/src/Components/Header/Header.jsx b/admin-frontend/src/Components/Header/Header.jsx new file mode 100644 index 0000000..50fe18d --- /dev/null +++ b/admin-frontend/src/Components/Header/Header.jsx @@ -0,0 +1,159 @@ +import { + Container, + Nav, + Navbar, + Image, + Stack, + OverlayTrigger, + Popover, + Button, +} from "react-bootstrap"; + +import { SunFill, MoonFill } from "react-bootstrap-icons"; +import { useState, useEffect } from "react"; + +import { useAuthContext } from "../../hooks/useAuthContext"; +import { useLogout } from "../../hooks/useLogout"; +import "./Header.scss"; +import toast from "react-hot-toast"; +import { useNavigate } from "react-router-dom"; + +const DEFAULT_AVATAR = "/default_avatar.jpg"; + +export default function Header() { + const { admin } = useAuthContext(); + const navigate = useNavigate(); + const { logout } = useLogout(); + const handleLogout = () => { + logout(); + }; + + const [theme, setTheme] = useState(() => { + const stored = localStorage.getItem("theme"); + return ( + stored || + (window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light") + ); + }); + + const checkLoggedIn = (e) => { + if (!admin) { + e.preventDefault(); + logout(); + navigate("/signin"); + toast.error("Please login or create an account."); + e.stopPropagation(); + } + }; + useEffect(() => { + document.documentElement.setAttribute("data-bs-theme", theme); + localStorage.setItem("theme", theme); + }, [theme]); + return ( + <> + + + + + + +
    + + +
    + + + + + + +
    +
    + + ); +} diff --git a/admin-frontend/src/Components/Header/Header.scss b/admin-frontend/src/Components/Header/Header.scss new file mode 100644 index 0000000..580c473 --- /dev/null +++ b/admin-frontend/src/Components/Header/Header.scss @@ -0,0 +1,72 @@ +@import "../../scss/variables"; + +@import "../../../node_modules/bootstrap/scss/bootstrap-grid.scss"; +@import "../../../node_modules/bootstrap/scss/grid"; +@import "../../../node_modules//bootstrap/scss/mixins"; + +.header-divider { + border: 1.5px solid $header-text; + height: 75%; + margin-left: 1em; + background-color: $header-text; +} + +.bg-header { + background-color: $header-footer-bg; +} + +#navbar_item { + color: $header-text; + text-transform: uppercase; + @include media-breakpoint-down(xl) { + font-size: 1.1em; + } + @include media-breakpoint-up(xxl) { + font-size: 1.5em; + } +} + +.search-container { + padding-left: 10px; +} + +#dropdown_items { + color: $link-text; + font-weight: 500; + padding-left: 10px; + padding-right: 10px; + font-size: 1.2em; + &:hover { + background-color: $primary; + color: $header-text; + border-radius: 4px; + } +} + +#toggler { + background-color: $primary; + border: none; + margin-left: 10px; +} + +#profile_menu_divider { + margin: 2px 0 2px 0; + width: 100%; + height: 0.1em; + background-color: rgb(18, 18, 18); +} + +// added on 05/30/2024 +.cc-logo-header { + @include media-breakpoint-down(md) { + width: 4rem; + } + + @include media-breakpoint-up(md) { + width: 5rem; + } + + @include media-breakpoint-up(xxl) { + width: 7rem; + } +} diff --git a/admin-frontend/src/Components/ListView/ArticlesList.jsx b/admin-frontend/src/Components/ListView/ArticlesList.jsx new file mode 100644 index 0000000..32d6d7c --- /dev/null +++ b/admin-frontend/src/Components/ListView/ArticlesList.jsx @@ -0,0 +1,129 @@ +import { Card, Container, Stack, Row, Col, Dropdown } from "react-bootstrap"; +import "./ListView.scss"; +import { useDeleteArticle } from "../../hooks/useDeleteArticle"; +import { useUnpublishArticle } from "../../hooks/useUnpublishArticle"; +import { usePublishArticle } from "../../hooks/usePublishArticle"; +import { useLoadingSpinner } from "../../context/SpinnerContext"; +import { useNavigate } from "react-router-dom"; +export default function ArticlesList({ articles, setArticles }) { + const navigate = useNavigate(); + const { showSpinner, hideSpinner } = useLoadingSpinner(); + const { + deleteArticle, + isLoading: deleteIsLoading, + error: deleteError, + } = useDeleteArticle(); + const { + unpublishArticle, + isLoading: unpublishIsLoading, + error: unpublishError, + } = useUnpublishArticle(); + const { + publishArticle, + isLoading: publishIsLoading, + error: publishError, + } = usePublishArticle(); + + const handleDeleteArticle = async (id) => { + showSpinner(); + let json = await deleteArticle(id); + + if (json && !json.error) { + setArticles((prev) => prev.filter((article) => article.id !== id)); + } + hideSpinner(); + }; + + const handleUnpublishArticle = async (id) => { + showSpinner(); + let json = await unpublishArticle(id); + + if (json && !json.error) { + setArticles((prev) => + prev.map((article) => + article.id !== id ? article : { ...article, is_published: false } + ) + ); + } + hideSpinner(); + }; + + const handlePublishArticle = async (id) => { + showSpinner(); + let json = await publishArticle(id); + + if (json && !json.error) { + setArticles((prev) => + prev.map((article) => + article.id !== id ? article : { ...article, is_published: true } + ) + ); + } + hideSpinner(); + }; + return ( + + + {articles?.length === 0 ? ( +
    No articles found.
    + ) : ( +
    Displaying results...
    + )} + {articles && + articles.map((article) => ( + + + navigate(`/articles/${article.id}`)} + > +
    + {article.header.blocks[0].data.text} + | {article.author} +
    + {article.published_at && ( +
    + {new Date(article.published_at).toLocaleDateString()} +
    + )} + + +
    {article.id}
    +
    + {article.published_at ? "Published" : "Unpublished"} +
    + + + + handleDeleteArticle(article.id)} + > + Delete + + {!article.published_at && ( + handlePublishArticle(article.id)} + > + Publish + + )} + {article.published_at && ( + handleUnpublishArticle(article.id)} + > + Unpublish + + )} + + + +
    +
    + ))} +
    +
    + ); +} diff --git a/admin-frontend/src/Components/ListView/ListView.scss b/admin-frontend/src/Components/ListView/ListView.scss new file mode 100644 index 0000000..b2fb5f2 --- /dev/null +++ b/admin-frontend/src/Components/ListView/ListView.scss @@ -0,0 +1,23 @@ +@import "../../scss/variables"; +@import "../../../node_modules/bootstrap/scss/bootstrap-grid.scss"; +@import "../../../node_modules/bootstrap/scss/grid"; +@import "../../../node_modules/bootstrap/scss/variables"; +@import "../../../node_modules//bootstrap/scss/mixins"; + +.left-side-card { + &:hover { + cursor: pointer; + } +} + +@include color-mode(light) { + .item-card { + background-color: $article-editor-body-light; + } +} + +@include color-mode(dark) { + .item-card { + background-color: $article-editor-body-dark; + } +} diff --git a/admin-frontend/src/Components/ListView/UsersList.jsx b/admin-frontend/src/Components/ListView/UsersList.jsx new file mode 100644 index 0000000..7488d42 --- /dev/null +++ b/admin-frontend/src/Components/ListView/UsersList.jsx @@ -0,0 +1,128 @@ +import { + Card, + Container, + Stack, + Row, + Col, + Image, + Dropdown, +} from "react-bootstrap"; +import "./ListView.scss"; +import { useDeleteUser } from "../../hooks/useDeleteUser"; +import { useGiveAdmin } from "../../hooks/useGiveAdmin"; +import { useRemoveAdmin } from "../../hooks/useRemoveAdmin"; +import { useLoadingSpinner } from "../../context/SpinnerContext"; +import { useNavigate } from "react-router-dom"; + +const DEFAULT_AVATAR = "/default_avatar.jpg"; + +export default function UsersList({ users, setUsers }) { + const navigate = useNavigate(); + const { deleteUser } = useDeleteUser(); + const { giveAdmin } = useGiveAdmin(); + const { removeAdmin } = useRemoveAdmin(); + const { showSpinner, hideSpinner } = useLoadingSpinner(); + + const handleDeleteUser = async (id) => { + showSpinner(); + let json = await deleteUser(id); + if (json && !json.error) { + setUsers((prev) => prev.filter((user) => user.id !== id)); + } + hideSpinner(); + }; + + const handleGiveAdmin = async (id) => { + showSpinner(); + let json = await giveAdmin(id); + if (json && !json.error) { + setUsers((prev) => + prev.map((user) => (user.id !== id ? user : { ...user, role: "admin" })) + ); + } + hideSpinner(); + }; + + const handleRemoveAdmin = async (id) => { + showSpinner(); + let json = await removeAdmin(id); + if (json && !json.error) { + setUsers((prev) => + prev.map((user) => (user.id !== id ? user : { ...user, role: "user" })) + ); + } + hideSpinner(); + }; + + return ( + + + {users && users.length === 0 ? ( +
    No users found.
    + ) : ( +
    Displaying results...
    + )} + {users && + users.map((user) => ( + + + navigate(`/users/${user.id}`)} + > + + + + + +
    + {user.username}{" "} + + | {user.first_name} {user.last_name} + +
    +
    + {user.email} +
    + +
    + + +
    + {user?.role === "admin" && "Admin"} +
    +
    {user.id}
    + + + + Edit + handleDeleteUser(user.id)}> + Delete + + handleRemoveAdmin(user.id) + : () => handleGiveAdmin(user.id) + } + > + {user?.role === "admin" ? "Remove Admin" : "Give Admin"} + + + + +
    +
    + ))} +
    +
    + ); +} diff --git a/admin-frontend/src/Components/LoadingSpinner/LoadingSpinner.jsx b/admin-frontend/src/Components/LoadingSpinner/LoadingSpinner.jsx new file mode 100644 index 0000000..28539be --- /dev/null +++ b/admin-frontend/src/Components/LoadingSpinner/LoadingSpinner.jsx @@ -0,0 +1,11 @@ +import { Spinner } from "react-bootstrap"; +import "./LoadingSpinner.scss"; +import { useState } from "react"; + +export default function LoadingSpinner() { + return ( +
    + +
    + ); +} diff --git a/admin-frontend/src/Components/LoadingSpinner/LoadingSpinner.scss b/admin-frontend/src/Components/LoadingSpinner/LoadingSpinner.scss new file mode 100644 index 0000000..fec0173 --- /dev/null +++ b/admin-frontend/src/Components/LoadingSpinner/LoadingSpinner.scss @@ -0,0 +1,17 @@ +.fullscreen-spinner-container { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: rgba(255, 255, 255, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; +} + +.fullscreen-spinner{ + width: 5rem !important; + height: 5rem !important; +} \ No newline at end of file diff --git a/admin-frontend/src/Components/SearchBar/ArticleSearchBar.jsx b/admin-frontend/src/Components/SearchBar/ArticleSearchBar.jsx new file mode 100644 index 0000000..74af5fa --- /dev/null +++ b/admin-frontend/src/Components/SearchBar/ArticleSearchBar.jsx @@ -0,0 +1,114 @@ +import { useState } from "react"; +import { Form, Row, Col, Container, Button } from "react-bootstrap"; +import { useSearchArticles } from "../../hooks/useSearchArticles"; +import "./SearchBar.scss"; +import { useLoadingSpinner } from "../../context/SpinnerContext"; +import { useSearchParams } from "react-router-dom"; + +export default function ArticleSearchBar({ onSearch }) { + const { showSpinner, hideSpinner } = useLoadingSpinner(); + + const [urlSearchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useState({ + id: urlSearchParams.get("id") || "", + author_id: urlSearchParams.get("author_id") || "", + title: urlSearchParams.get("title") || "", + }); + + const { searchArticles } = useSearchArticles(); + + const handleChange = (e) => { + setSearchParams({ ...searchParams, [e.target.name]: e.target.value }); + }; + + const handleSearch = async (e) => { + e.preventDefault(); + + const params = { + id: searchParams.id ? parseInt(searchParams.id) : null, + author_id: searchParams.author_id + ? parseInt(searchParams.author_id) + : null, + title: searchParams.title || null, + }; + + showSpinner(); + const results = await searchArticles(params); + if (results) onSearch?.(results, params); + hideSpinner(); + }; + + const handleShowAll = async () => { + const emptyParams = { + id: null, + author_id: null, + title: null, + }; + + setSearchParams({ + id: "", + author_id: "", + title: "", + }); + + const results = await searchArticles(emptyParams); + if (results) onSearch?.(results, emptyParams); + }; + + return ( + +
    + + + Title + + + + + + + + Author ID + + + + + + + + Article ID + + + + + + + + + + + + + +
    +
    + ); +} diff --git a/admin-frontend/src/Components/SearchBar/SearchBar.scss b/admin-frontend/src/Components/SearchBar/SearchBar.scss new file mode 100644 index 0000000..8719690 --- /dev/null +++ b/admin-frontend/src/Components/SearchBar/SearchBar.scss @@ -0,0 +1,21 @@ +@import "../../scss/variables"; +@import "../../../node_modules/bootstrap/scss/bootstrap-grid.scss"; +@import "../../../node_modules/bootstrap/scss/grid"; +@import "../../../node_modules/bootstrap/scss/variables"; +@import "../../../node_modules//bootstrap/scss/mixins"; + +@include color-mode(light) { + .search-container { + background-color: $article-editor-body-light; + } +} + +@include color-mode(dark) { + body { + background-color: $primary-bg-dark; + } + + .search-container { + background-color: $article-editor-body-dark; + } +} diff --git a/admin-frontend/src/Components/SearchBar/UserSearchBar.jsx b/admin-frontend/src/Components/SearchBar/UserSearchBar.jsx new file mode 100644 index 0000000..c020323 --- /dev/null +++ b/admin-frontend/src/Components/SearchBar/UserSearchBar.jsx @@ -0,0 +1,165 @@ +import { useState } from "react"; +import { Form, Row, Col, Container, Button } from "react-bootstrap"; +import { useSearchUsers } from "../../hooks/useSearchUsers"; +import "./SearchBar.scss"; +import { useLoadingSpinner } from "../../context/SpinnerContext"; +import { useSearchParams } from "react-router-dom"; +import { useGetAdmins } from "../../hooks/useGetAdmins"; + +export default function UserSearchBar({ onSearch }) { + const { showSpinner, hideSpinner } = useLoadingSpinner(); + + const { getAdmins } = useGetAdmins(); + + const [searchParams, setSearchParams] = useSearchParams(); + + const handleShowAdmins = async () => { + showSpinner(); + setSearchParams({ type: "admin" }); + const results = await getAdmins(); + if (results) onSearch?.(results, { type: "admin" }); + hideSpinner(); + }; + + const [formParams, setFormParams] = useState({ + username: searchParams.get("username") || "", + first_name: searchParams.get("first_name") || "", + last_name: searchParams.get("last_name") || "", + email: searchParams.get("email") || "", + id: searchParams.get("id") ? parseInt(searchParams.get("id")) : "", + }); + + const { searchUsers } = useSearchUsers(); + + const handleChange = (e) => { + setFormParams({ ...formParams, [e.target.name]: e.target.value }); + }; + + const handleSearch = async (e) => { + e.preventDefault(); + + const params = { + username: formParams.username || null, + first_name: formParams.first_name || null, + last_name: formParams.last_name || null, + email: formParams.email || null, + id: formParams.id ? parseInt(formParams.id) : null, + }; + + showSpinner(); + const results = await searchUsers(params); + if (results) onSearch?.(results, params); + hideSpinner(); + }; + + const handleShowAll = async () => { + const emptyParams = { + username: null, + first_name: null, + last_name: null, + id: null, + email: null, + }; + + setFormParams({ + username: "", + first_name: "", + last_name: "", + id: "", + email: "", + }); + + showSpinner(); + const results = await searchUsers(emptyParams); + if (results) onSearch?.(results, emptyParams); + hideSpinner(); + }; + + return ( + +
    + + + Username + + + + + + + + First Name + + + + + + + + Last Name + + + + + + + + Email + + + + + + + + ID + + + + + + + + + + + + + + + + +
    +
    + ); +} diff --git a/admin-frontend/src/Components/SignIn/SigninCard.jsx b/admin-frontend/src/Components/SignIn/SigninCard.jsx new file mode 100644 index 0000000..0043f8b --- /dev/null +++ b/admin-frontend/src/Components/SignIn/SigninCard.jsx @@ -0,0 +1,136 @@ +import { useState } from "react"; +import { + Form, + Button, + InputGroup, + Container, + Row, + Col, + Stack, + Card, +} from "react-bootstrap"; +import * as auth from "../auth/auth"; +import { useLogin } from "../../hooks/useLogin"; +import { EyeFill, EyeSlashFill } from "react-bootstrap-icons"; + +export default function SigninCard() { + const [formVal, setFormVal] = useState({ + username: "", + password: "", + }); + + const [showPassword, setShowPassword] = useState(false); + const [isValidated, setValidated] = useState(false); + const [errorMessages, setErrorMessages] = useState({}); + const { login, isLoading, error } = useLogin(); + + const handleInput = (e) => { + const { name, value } = e.target; + setFormVal((prevState) => ({ + ...prevState, + [name]: value, + })); + }; + + const handlePasswordToggle = () => { + setShowPassword(!showPassword); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + + const errMessagesList = {}; + const checkEmpty = auth.validationFunctions.checkEmpty; + + // Validation + for (const fieldName in formVal) { + const validateResult = checkEmpty(fieldName, formVal[fieldName]); + if (typeof validateResult === "string") { + errMessagesList[fieldName] = validateResult; + } + } + setErrorMessages(errMessagesList); + + if (Object.keys(errMessagesList).length === 0) { + // If no errors, proceed with login + try { + await login({ username: formVal.username, password: formVal.password }); + // Redirect or perform other actions on successful login + } catch (err) { + setErrorMessages({ form: "Invalid credentials" }); + } + } else { + setValidated(true); + } + }; + + return ( + + + +
    +

    Login

    + + + + {errorMessages.username || ""} + + + + + + + + {errorMessages.password || ""} + + + + {errorMessages.form && ( +

    {errorMessages.form}

    + )} + {error &&

    {error}

    } +
    + +
    +
    +
    +
    +
    + ); +} diff --git a/admin-frontend/src/Components/UserData/UserComments.jsx b/admin-frontend/src/Components/UserData/UserComments.jsx new file mode 100644 index 0000000..36e1157 --- /dev/null +++ b/admin-frontend/src/Components/UserData/UserComments.jsx @@ -0,0 +1,37 @@ +import { Container, Row, Col, Dropdown, Card, Stack } from "react-bootstrap"; + +export default function UserComments({ comments, onDelete }) { + return ( + +

    Comments

    + {comments.length === 0 ? ( +

    No comments posted yet.

    + ) : ( + + {comments.map((comment) => ( + + + +
    + {new Date(comment.created_at).toLocaleString()} +
    +
    {comment.content}
    + + + + + + onDelete(comment.id)}> + Delete + + + + +
    +
    + ))} +
    + )} +
    + ); +} diff --git a/admin-frontend/src/Components/UserData/UserData.jsx b/admin-frontend/src/Components/UserData/UserData.jsx new file mode 100644 index 0000000..af42628 --- /dev/null +++ b/admin-frontend/src/Components/UserData/UserData.jsx @@ -0,0 +1,51 @@ +import { Container, Row, Col, Image, Card } from "react-bootstrap"; + +const DEFAULT_AVATAR = "/default_avatar.jpg"; + +export default function UserData({ user }) { + return ( + <> + + + + + + + + +
    + {user.first_name} {user.last_name} +
    + + {user.role && ( +
    + {user.role} +
    + )} + +
    + Username: + {user.username} +
    + +
    + Email: + {user.email} +
    + +
    + User ID: + {user.id} +
    + +
    +
    +
    + + ); +} diff --git a/admin-frontend/src/Components/auth/auth.js b/admin-frontend/src/Components/auth/auth.js new file mode 100644 index 0000000..97428a8 --- /dev/null +++ b/admin-frontend/src/Components/auth/auth.js @@ -0,0 +1,78 @@ +// error labels +const errorLabels = { + username: "username", + password: "password", + oldPassword: "old password", + newPassword: "new password", + confirmNewPassword: "confirm new password", + email: "email", + fname: "first name", + lname: "last name", + confirmPassword: "confirm password", +}; + +// Regex patterns +const patterns = { + username: /^[a-z\d]{5,12}$/i, + password: /^[\d\w@-]{8,20}$/i, + email: /^([a-z\d\.-]+)@([a-z\d-]+)\.([a-z]{3,8})$/, + // email_address @ domain . com +}; + +// functions for validating input fields (add more if possible) +export const validationFunctions = { + checkEmpty: (name = "", value1 = "", value2 = "") => + value1.length > 0 || `${errorLabels[name]} field is required`, + noSpaces: (name = "", value1 = "", value2 = "") => + !value1.includes(" ") || `${errorLabels[name]} cannot have any spaces`, + checkPasswordLength: (name = "", value1 = "", value2 = "") => + value1.length >= 8 || + value1.length === 0 || + `${errorLabels[name]} should be no less than 8 characters in length`, + + checkPasswordMatch: (name = "", value1 = "", value2 = "") => + value1 === value2 || value1.length === 0 || "The password does not match", + + checkValidEmail: (name = "", value1 = "", value2 = "") => { + const regex = patterns[name]; + + return regex.test(value1) || value1.length === 0 ? true : "Invalid email"; + }, + checkboxRequired: (name = "", value1 = false, value2 = "") => { + return value1 || "This is required."; + }, +}; + +// an object containing input fields (keys) +// and their associated validation funciton (values as an array) +export const formValidation = { + username: [validationFunctions.checkEmpty, validationFunctions.noSpaces], + fname: [validationFunctions.checkEmpty], + lname: [validationFunctions.checkEmpty], + email: [validationFunctions.checkEmpty, validationFunctions.checkValidEmail], + password: [ + validationFunctions.checkEmpty, + validationFunctions.checkPasswordLength, + validationFunctions.noSpaces, + ], + oldPassword: [ + validationFunctions.checkEmpty, + validationFunctions.checkPasswordLength, + ], + newPassword: [ + validationFunctions.checkEmpty, + validationFunctions.checkPasswordLength, + validationFunctions.noSpaces, + ], + confirmPassword: [ + validationFunctions.checkEmpty, + validationFunctions.checkPasswordMatch, + validationFunctions.noSpaces, + ], + confirmNewPassword: [ + validationFunctions.checkEmpty, + validationFunctions.checkPasswordMatch, + validationFunctions.noSpaces, + ], + confirmNewPasswordCheckbox: [validationFunctions.checkboxRequired], +}; diff --git a/admin-frontend/src/assets/logo.png b/admin-frontend/src/assets/logo.png new file mode 100644 index 0000000..b729641 Binary files /dev/null and b/admin-frontend/src/assets/logo.png differ diff --git a/admin-frontend/src/assets/react.svg b/admin-frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/admin-frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin-frontend/src/context/AuthContext.jsx b/admin-frontend/src/context/AuthContext.jsx new file mode 100644 index 0000000..4cd4bb6 --- /dev/null +++ b/admin-frontend/src/context/AuthContext.jsx @@ -0,0 +1,39 @@ +import { createContext, useReducer, useEffect } from "react"; + +export const AuthContext = createContext(); + +export const authReducer = (state, action) => { + switch (action.type) { + case "LOGIN": + return { admin: action.payload, isAuthChecked: true }; + case "LOGOUT": + return { admin: null, isAuthChecked: true }; + case "AUTH_CHECKED": + return { admin: null, isAuthChecked: true }; + default: + return state; + } +}; + +export const AuthContextProvider = ({ children }) => { + const [state, dispatch] = useReducer(authReducer, { + admin: null, + }); + + useEffect(() => { + const admin = JSON.parse(localStorage.getItem("admin")); + if (admin) { + dispatch({ type: "LOGIN", payload: admin }); + } else { + dispatch({ type: "AUTH_CHECKED" }); + } + }, []); + + console.log("AuthContext state: ", state); + + return ( + + {children} + + ); +}; diff --git a/admin-frontend/src/context/SpinnerContext.jsx b/admin-frontend/src/context/SpinnerContext.jsx new file mode 100644 index 0000000..dd29a5a --- /dev/null +++ b/admin-frontend/src/context/SpinnerContext.jsx @@ -0,0 +1,19 @@ +import { useState, useContext, createContext } from "react"; + +const SpinnerContext = createContext(); + +export const SpinnerProvider = ({ children }) => { + const [spinnerIsShowing, setSpinnerIsShowing] = useState(false); + const showSpinner = () => setSpinnerIsShowing(true); + const hideSpinner = () => setSpinnerIsShowing(false); + + return ( + + {children} + + ); +}; + +export const useLoadingSpinner = () => useContext(SpinnerContext); diff --git a/admin-frontend/src/hooks/useAuthContext.jsx b/admin-frontend/src/hooks/useAuthContext.jsx new file mode 100644 index 0000000..6279b8c --- /dev/null +++ b/admin-frontend/src/hooks/useAuthContext.jsx @@ -0,0 +1,12 @@ +import { AuthContext } from "../context/AuthContext"; +import { useContext } from "react"; + +export const useAuthContext = () => { + const context = useContext(AuthContext); + + if (!context) { + throw Error("useAuthContext must be used inside an AuthContextProvider"); + } + + return context; +}; diff --git a/admin-frontend/src/hooks/useDeleteArticle.jsx b/admin-frontend/src/hooks/useDeleteArticle.jsx new file mode 100644 index 0000000..a7e206b --- /dev/null +++ b/admin-frontend/src/hooks/useDeleteArticle.jsx @@ -0,0 +1,44 @@ +import { useState } from "react"; +import toast from "react-hot-toast"; +export const useDeleteArticle = () => { + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const apiUrl = import.meta.env.VITE_API_URL; + const admin = JSON.parse(localStorage.getItem("admin")); + + const deleteArticle = async (articleId) => { + setIsLoading(true); + setError(null); + + try { + const response = await fetch( + `${apiUrl}/api/admin/articles/${articleId}`, + { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${admin?.token}`, + }, + } + ); + + const json = await response.json(); + + if (!response.ok) { + setError(json.error); + toast.error(json.error); + return; + } + + toast.success("Successfully deleted"); + return json; + } catch (err) { + setError("Something went wrong. Couldn't delete article."); + toast.error("Something went wrong. Couldn't delete article."); + } finally { + setIsLoading(false); + } + }; + + return { deleteArticle, isLoading, error }; +}; diff --git a/admin-frontend/src/hooks/useDeleteComment.jsx b/admin-frontend/src/hooks/useDeleteComment.jsx new file mode 100644 index 0000000..3af7ba5 --- /dev/null +++ b/admin-frontend/src/hooks/useDeleteComment.jsx @@ -0,0 +1,43 @@ +import { useState } from "react"; +import toast from "react-hot-toast"; + +export const useDeleteComment = () => { + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const apiUrl = import.meta.env.VITE_API_URL; + const admin = JSON.parse(localStorage.getItem("admin")); + + const deleteComment = async (commentId) => { + setIsLoading(true); + setError(null); + + try { + const response = await fetch( + `${apiUrl}/api/admin/users/comments/${commentId}`, + { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${admin?.token}`, + }, + } + ); + + const json = await response.json(); + + if (!response.ok) { + setError(json.error); + toast.error(json.error); + return; + } + } catch (err) { + console.log(err); + setError("Something went wrong. Couldn't delete comment."); + toast.error("Something went wrong. Couldn't delete comment."); + } finally { + setIsLoading(false); + } + }; + + return { deleteComment, isLoading, error }; +}; diff --git a/admin-frontend/src/hooks/useDeleteUser.jsx b/admin-frontend/src/hooks/useDeleteUser.jsx new file mode 100644 index 0000000..49198c0 --- /dev/null +++ b/admin-frontend/src/hooks/useDeleteUser.jsx @@ -0,0 +1,41 @@ +import { useState } from "react"; +import toast from "react-hot-toast"; +export const useDeleteUser = () => { + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const apiUrl = import.meta.env.VITE_API_URL; + const admin = JSON.parse(localStorage.getItem("admin")); + + const deleteUser = async (articleId) => { + setIsLoading(true); + setError(null); + + try { + const response = await fetch(`${apiUrl}/api/admin/users/${articleId}`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${admin?.token}`, + }, + }); + + const json = await response.json(); + + if (!response.ok) { + setError(json.error); + toast.error(json.error); + return; + } + + toast.success("Successfully deleted"); + return json; + } catch (err) { + setError("Something went wrong. Couldn't delete user."); + toast.error("Something went wrong. Couldn't delete user."); + } finally { + setIsLoading(false); + } + }; + + return { deleteUser, isLoading, error }; +}; diff --git a/admin-frontend/src/hooks/useGetAdmins.jsx b/admin-frontend/src/hooks/useGetAdmins.jsx new file mode 100644 index 0000000..5105ff7 --- /dev/null +++ b/admin-frontend/src/hooks/useGetAdmins.jsx @@ -0,0 +1,42 @@ +import { useState } from "react"; +import toast from "react-hot-toast"; +export const useGetAdmins = () => { + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const apiUrl = import.meta.env.VITE_API_URL; + const admin = JSON.parse(localStorage.getItem("admin")); + + const getAdmins = async () => { + setIsLoading(true); + setError(null); + + try { + const response = await fetch(`${apiUrl}/api/admin`, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${admin?.token}`, + }, + }); + + const json = await response.json(); + if (!response.ok) { + setError(json.error); + toast.error(json.error); + } + + if (response.ok & (json.length === 0)) { + setError("No admins found."); + toast.error("No admins found."); + } + + return { users: json.admins }; + } catch (err) { + setError("Something went wrong. Couldn't retrieve admins."); + } finally { + setIsLoading(false); + } + }; + + return { getAdmins, isLoading, error }; +}; diff --git a/admin-frontend/src/hooks/useGetArticleByID.jsx b/admin-frontend/src/hooks/useGetArticleByID.jsx new file mode 100644 index 0000000..ad99cf1 --- /dev/null +++ b/admin-frontend/src/hooks/useGetArticleByID.jsx @@ -0,0 +1,44 @@ +import { useState } from "react"; +import toast from "react-hot-toast"; +export const useGetArticleByID = () => { + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const apiUrl = import.meta.env.VITE_API_URL; + const admin = JSON.parse(localStorage.getItem("admin")); + + const getArticleByID = async (articleId) => { + setIsLoading(true); + setError(null); + + try { + const response = await fetch( + `${apiUrl}/api/admin/articles/${articleId}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${admin?.token}`, + }, + } + ); + + const json = await response.json(); + if (!response.ok) { + setError(json.error); + toast.error(json.error); + } + + if (response.ok & (json.length === 0)) { + setError("No articles found with this ID."); + toast.error("No articles found with this ID."); + } + return json.article; + } catch (err) { + setError("Something went wrong. Couldn't retrieve article."); + } finally { + setIsLoading(false); + } + }; + + return { getArticleByID, isLoading, error }; +}; diff --git a/admin-frontend/src/hooks/useGetCommentsByUser.jsx b/admin-frontend/src/hooks/useGetCommentsByUser.jsx new file mode 100644 index 0000000..268ee16 --- /dev/null +++ b/admin-frontend/src/hooks/useGetCommentsByUser.jsx @@ -0,0 +1,44 @@ +import { useState } from "react"; +import toast from "react-hot-toast"; + +export const useGetCommentsByUser = () => { + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const apiUrl = import.meta.env.VITE_API_URL; + const admin = JSON.parse(localStorage.getItem("admin")); + + const getCommentsByUser = async (userID) => { + setIsLoading(true); + setError(null); + + try { + const response = await fetch( + `${apiUrl}/api/admin/users/${userID}/comments`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${admin?.token}`, + }, + } + ); + + const json = await response.json(); + + if (!response.ok) { + setError(json.error); + toast.error(json.error); + return; + } + return json; + } catch (err) { + console.log(err); + setError("Something went wrong. Couldn't get user's comment."); + toast.error("Something went wrong. Couldn't get user's comment."); + } finally { + setIsLoading(false); + } + }; + + return { getCommentsByUser, isLoading, error }; +}; diff --git a/admin-frontend/src/hooks/useGetUserData.jsx b/admin-frontend/src/hooks/useGetUserData.jsx new file mode 100644 index 0000000..4d74d43 --- /dev/null +++ b/admin-frontend/src/hooks/useGetUserData.jsx @@ -0,0 +1,41 @@ +import { useState } from "react"; +import toast from "react-hot-toast"; +export const useGetUserData = () => { + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const apiUrl = import.meta.env.VITE_API_URL; + const admin = JSON.parse(localStorage.getItem("admin")); + + const getUserData = async (userID) => { + setIsLoading(true); + setError(null); + + try { + const response = await fetch(`${apiUrl}/api/admin/users/${userID}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${admin?.token}`, + }, + }); + + const json = await response.json(); + if (!response.ok) { + setError(json.error); + toast.error(json.error); + } + + if (response.ok & (json.length === 0)) { + setError("No user found with this ID."); + toast.error("No user found with this ID."); + } + return json.user; + } catch (err) { + setError("Something went wrong. Couldn't retrieve user data."); + } finally { + setIsLoading(false); + } + }; + + return { getUserData, isLoading, error }; +}; diff --git a/admin-frontend/src/hooks/useGiveAdmin.jsx b/admin-frontend/src/hooks/useGiveAdmin.jsx new file mode 100644 index 0000000..de0e95c --- /dev/null +++ b/admin-frontend/src/hooks/useGiveAdmin.jsx @@ -0,0 +1,42 @@ +import { useState } from "react"; +import toast from "react-hot-toast"; +export const useGiveAdmin = () => { + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const apiUrl = import.meta.env.VITE_API_URL; + const admin = JSON.parse(localStorage.getItem("admin")); + + const giveAdmin = async (userId) => { + setIsLoading(true); + setError(null); + + try { + const response = await fetch(`${apiUrl}/api/admin`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${admin?.token}`, + }, + body: JSON.stringify({ user_id: userId }), + }); + + const json = await response.json(); + + if (!response.ok) { + setError(json.error); + toast.error(json.error); + return; + } + + toast.success("Successfully gave admin"); + return json; + } catch (err) { + setError("Something went wrong. Couldn't give admin."); + toast.error("Something went wrong. Couldn't give admin."); + } finally { + setIsLoading(false); + } + }; + + return { giveAdmin, isLoading, error }; +}; diff --git a/admin-frontend/src/hooks/useLogin.jsx b/admin-frontend/src/hooks/useLogin.jsx new file mode 100644 index 0000000..6551cf7 --- /dev/null +++ b/admin-frontend/src/hooks/useLogin.jsx @@ -0,0 +1,51 @@ +import { useState } from "react"; +import { useAuthContext } from "./useAuthContext"; +import { useNavigate } from "react-router-dom"; +import toast from "react-hot-toast"; + +export const useLogin = () => { + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const { dispatch } = useAuthContext(); + const navigate = useNavigate(); + const apiUrl = import.meta.env.VITE_API_URL; //change to .env + // const api = process.env.REACT_APP_API_URL + + const login = async (user, error) => { + setIsLoading(true); + setError(null); + + const { username, password } = user; + try { + const response = await fetch(`${apiUrl}/api/admin/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + + const json = await response.json(); + + if (!response.ok) { + setIsLoading(false); + setError(json.error); + } + if (response.ok) { + //save user to local storage + localStorage.setItem("admin", JSON.stringify(json)); + + //update authcontext + dispatch({ type: "LOGIN", payload: json }); + + setIsLoading(false); + console.log(localStorage); + navigate("/"); + } + } catch (err) { + setError("Something went wrong. Please try again."); + toast.error("Something went wrong. Please try again."); + setIsLoading(false); + } + }; + + return { login, isLoading, error }; +}; diff --git a/admin-frontend/src/hooks/useLogout.jsx b/admin-frontend/src/hooks/useLogout.jsx new file mode 100644 index 0000000..d5b1060 --- /dev/null +++ b/admin-frontend/src/hooks/useLogout.jsx @@ -0,0 +1,17 @@ +import { useAuthContext } from "./useAuthContext"; + +export const useLogout = () => { + { + const { dispatch } = useAuthContext(); + + const logout = () => { + //remove localstorage + localStorage.removeItem("admin"); + + //logout for authcontext + dispatch({ type: "LOGOUT" }); + }; + + return { logout }; + } +}; diff --git a/admin-frontend/src/hooks/usePublishArticle.jsx b/admin-frontend/src/hooks/usePublishArticle.jsx new file mode 100644 index 0000000..3887c22 --- /dev/null +++ b/admin-frontend/src/hooks/usePublishArticle.jsx @@ -0,0 +1,44 @@ +import { useState } from "react"; +import toast from "react-hot-toast"; +export const usePublishArticle = () => { + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const apiUrl = import.meta.env.VITE_API_URL; + const admin = JSON.parse(localStorage.getItem("admin")); + + const publishArticle = async (articleId) => { + setIsLoading(true); + setError(null); + + try { + const response = await fetch( + `${apiUrl}/api/admin/articles/${articleId}/publish`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${admin?.token}`, + }, + } + ); + + const json = await response.json(); + + if (!response.ok) { + setError(json.error); + toast.error(json.error); + return; + } + + toast.success("Successfully published."); + return json; + } catch (err) { + setError("Something went wrong. Couldn't publish article."); + toast.error("Something went wrong. Couldn't publish article."); + } finally { + setIsLoading(false); + } + }; + + return { publishArticle, isLoading, error }; +}; diff --git a/admin-frontend/src/hooks/useRemoveAdmin.jsx b/admin-frontend/src/hooks/useRemoveAdmin.jsx new file mode 100644 index 0000000..7aa0451 --- /dev/null +++ b/admin-frontend/src/hooks/useRemoveAdmin.jsx @@ -0,0 +1,42 @@ +import { useState } from "react"; +import toast from "react-hot-toast"; +export const useRemoveAdmin = () => { + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const apiUrl = import.meta.env.VITE_API_URL; + const admin = JSON.parse(localStorage.getItem("admin")); + + const removeAdmin = async (userId) => { + setIsLoading(true); + setError(null); + + try { + const response = await fetch(`${apiUrl}/api/admin`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${admin?.token}`, + }, + body: JSON.stringify({ user_id: userId }), + }); + + const json = await response.json(); + + if (!response.ok) { + setError(json.error); + toast.error(json.error); + return; + } + + toast.success("Successfully removed admin"); + return json; + } catch (err) { + setError("Something went wrong. Couldn't remove admin."); + toast.error("Something went wrong. Couldn't remove admin."); + } finally { + setIsLoading(false); + } + }; + + return { removeAdmin, isLoading, error }; +}; diff --git a/admin-frontend/src/hooks/useSearchArticles.jsx b/admin-frontend/src/hooks/useSearchArticles.jsx new file mode 100644 index 0000000..81cd1d3 --- /dev/null +++ b/admin-frontend/src/hooks/useSearchArticles.jsx @@ -0,0 +1,41 @@ +import { useState } from "react"; +import toast from "react-hot-toast"; + +export const useSearchArticles = () => { + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const apiUrl = import.meta.env.VITE_API_URL; + const admin = JSON.parse(localStorage.getItem("admin")); + + const searchArticles = async (searchParams) => { + setIsLoading(true); + setError(null); + + try { + const response = await fetch(`${apiUrl}/api/admin/articles`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${admin?.token}`, + }, + body: JSON.stringify(searchParams), + }); + + const json = await response.json(); + + if (!response.ok) { + setError(json.error); + toast.error(json.error); + return; + } + + return json; + } catch (err) { + setError("Something went wrong. Couldn't search for articles."); + } finally { + setIsLoading(false); + } + }; + + return { searchArticles, isLoading, error }; +}; diff --git a/admin-frontend/src/hooks/useSearchUsers.jsx b/admin-frontend/src/hooks/useSearchUsers.jsx new file mode 100644 index 0000000..390ed9c --- /dev/null +++ b/admin-frontend/src/hooks/useSearchUsers.jsx @@ -0,0 +1,42 @@ +import { useState } from "react"; +import toast from "react-hot-toast"; + +export const useSearchUsers = () => { + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const apiUrl = import.meta.env.VITE_API_URL; + const admin = JSON.parse(localStorage.getItem("admin")); + + const searchUsers = async (searchParams) => { + setIsLoading(true); + setError(null); + + try { + const response = await fetch(`${apiUrl}/api/admin/users`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${admin?.token}`, + }, + body: JSON.stringify(searchParams), + }); + + const json = await response.json(); + + if (!response.ok) { + setError(json.error); + toast.error(json.error); + return; + } + + return json; + } catch (err) { + setError("Something went wrong. Couldn't search for users."); + toast.error("Something went wrong. Couldn't search for users."); + } finally { + setIsLoading(false); + } + }; + + return { searchUsers, isLoading, error }; +}; diff --git a/admin-frontend/src/hooks/useUnpublishArticle.jsx b/admin-frontend/src/hooks/useUnpublishArticle.jsx new file mode 100644 index 0000000..852ce07 --- /dev/null +++ b/admin-frontend/src/hooks/useUnpublishArticle.jsx @@ -0,0 +1,44 @@ +import { useState } from "react"; +import toast from "react-hot-toast"; +export const useUnpublishArticle = () => { + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const apiUrl = import.meta.env.VITE_API_URL; + const admin = JSON.parse(localStorage.getItem("admin")); + + const unpublishArticle = async (articleId) => { + setIsLoading(true); + setError(null); + + try { + const response = await fetch( + `${apiUrl}/api/admin/articles/${articleId}/unpublish`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${admin?.token}`, + }, + } + ); + + const json = await response.json(); + + if (!response.ok) { + setError(json.error); + toast.error(json.error); + return; + } + + toast.success("Successfully unpublished."); + return json; + } catch (err) { + setError("Something went wrong. Couldn't unpublish article."); + toast.error("Something went wrong. Couldn't unpublish article."); + } finally { + setIsLoading(false); + } + }; + + return { unpublishArticle, isLoading, error }; +}; diff --git a/admin-frontend/src/main.jsx b/admin-frontend/src/main.jsx new file mode 100644 index 0000000..05e370f --- /dev/null +++ b/admin-frontend/src/main.jsx @@ -0,0 +1,108 @@ +import ReactDOM from "react-dom/client"; +import React from "react"; +import App from "./App.jsx"; +import { AuthContextProvider } from "./context/AuthContext"; +import { BrowserRouter } from "react-router-dom"; +import "./scss/custom.scss"; +import { SpinnerProvider } from "./context/SpinnerContext.jsx"; + +const root = ReactDOM.createRoot(document.getElementById("root")); +root.render( + + + + + + + + + +); + +//theme toggle listener +(() => { + "use strict"; + + const getStoredTheme = () => localStorage.getItem("theme"); + const setStoredTheme = (theme) => localStorage.setItem("theme", theme); + + const getPreferredTheme = () => { + const storedTheme = getStoredTheme(); + if (storedTheme) { + return storedTheme; + } + + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + }; + + const setTheme = (theme) => { + if (theme === "auto") { + document.documentElement.setAttribute( + "data-bs-theme", + window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light" + ); + } else { + document.documentElement.setAttribute("data-bs-theme", theme); + } + }; + + setTheme(getPreferredTheme()); + + const showActiveTheme = (theme, focus = false) => { + const themeSwitcher = document.querySelector("#bd-theme"); + + if (!themeSwitcher) { + return; + } + + const themeSwitcherText = document.querySelector("#bd-theme-text"); + const activeThemeIcon = document.querySelector(".theme-icon-active use"); + const btnToActive = document.querySelector( + `[data-bs-theme-value="${theme}"]` + ); + const svgOfActiveBtn = btnToActive + .querySelector("svg use") + .getAttribute("href"); + + document.querySelectorAll("[data-bs-theme-value]").forEach((element) => { + element.classList.remove("active"); + element.setAttribute("aria-pressed", "false"); + }); + + btnToActive.classList.add("active"); + btnToActive.setAttribute("aria-pressed", "true"); + activeThemeIcon.setAttribute("href", svgOfActiveBtn); + const themeSwitcherLabel = `${themeSwitcherText.textContent} (${btnToActive.dataset.bsThemeValue})`; + themeSwitcher.setAttribute("aria-label", themeSwitcherLabel); + + if (focus) { + themeSwitcher.focus(); + } + }; + + window + .matchMedia("(prefers-color-scheme: dark)") + .addEventListener("change", () => { + const storedTheme = getStoredTheme(); + + if (storedTheme !== "light" && storedTheme !== "dark") { + setTheme(getPreferredTheme()); + } + }); + + window.addEventListener("DOMContentLoaded", () => { + showActiveTheme(getPreferredTheme()); + document.querySelectorAll("[data-bs-theme-value]").forEach((toggle) => { + toggle.addEventListener("click", () => { + const theme = toggle.getAttribute("data-bs-theme-value"); + setStoredTheme(theme); + setTheme(theme); + showActiveTheme(theme, true); + }); + }); + }); +})(); diff --git a/admin-frontend/src/pages/ArticlesPage/ArticleView.jsx b/admin-frontend/src/pages/ArticlesPage/ArticleView.jsx new file mode 100644 index 0000000..850cd2b --- /dev/null +++ b/admin-frontend/src/pages/ArticlesPage/ArticleView.jsx @@ -0,0 +1,34 @@ +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import Article from "../../Components/Article/Article.jsx"; +import { useGetArticleByID } from "../../hooks/useGetArticleByID.jsx"; + +export default function ArticleView() { + const [article, setArticle] = useState(); + const params = useParams(); + const { getArticleByID } = useGetArticleByID(); + + useEffect(() => { + const fetchArticle = async () => { + try { + let fetchedArticle = await getArticleByID(params.articleID); + console.log("Fetched article:", fetchedArticle); + + if (fetchedArticle) { + setArticle(fetchedArticle); + } + } catch (err) { + console.log(err); + console.log("Error fetching article "); + } + }; + + fetchArticle(); + }, [params.articleID]); + return ( + <> + {/*conditional rendering based on data being fetched*/} + {article &&
    } + + ); +} diff --git a/admin-frontend/src/pages/ArticlesPage/ArticlesPage.jsx b/admin-frontend/src/pages/ArticlesPage/ArticlesPage.jsx new file mode 100644 index 0000000..06775a6 --- /dev/null +++ b/admin-frontend/src/pages/ArticlesPage/ArticlesPage.jsx @@ -0,0 +1,44 @@ +import ArticlesList from "../../Components/ListView/ArticlesList"; +import ArticleSearchBar from "../../Components/SearchBar/ArticleSearchBar"; +import { useState, useEffect } from "react"; +import { useSearchArticles } from "../../hooks/useSearchArticles"; +import { useSearchParams } from "react-router-dom"; + +export default function ArticlesPage() { + const [articles, setArticles] = useState([]); + const { searchArticles } = useSearchArticles(); + const [searchParams, setSearchParams] = useSearchParams(); + + useEffect(() => { + const query = { + id: searchParams.get("id") ? parseInt(searchParams.get("id")) : null, + author_id: searchParams.get("author_id") + ? parseInt(searchParams.get("author_id")) + : null, + title: searchParams.get("title") || null, + }; + (async () => { + const results = await searchArticles(query); + setArticles(results?.articles || []); + })(); + }, [searchParams]); + + const handleSearch = (results, paramsObject) => { + setArticles(results?.articles || []); + + const cleanParams = {}; + for (const [key, value] of Object.entries(paramsObject)) { + if (value !== null && value !== "") { + cleanParams[key] = value; + } + } + setSearchParams(cleanParams); + }; + + return ( + <> + + + + ); +} diff --git a/admin-frontend/src/pages/BasePage.jsx b/admin-frontend/src/pages/BasePage.jsx new file mode 100644 index 0000000..3635c5d --- /dev/null +++ b/admin-frontend/src/pages/BasePage.jsx @@ -0,0 +1,14 @@ +import { Outlet } from "react-router-dom"; +import Header from "../Components/Header/Header"; +import { Container } from "react-bootstrap"; +import Footer from "../Components/Footer/Footer"; + +export default function BasePage() { + return ( + +
    + +
    + + ); +} diff --git a/admin-frontend/src/pages/HomePage/Home.jsx b/admin-frontend/src/pages/HomePage/Home.jsx new file mode 100644 index 0000000..8d288ab --- /dev/null +++ b/admin-frontend/src/pages/HomePage/Home.jsx @@ -0,0 +1,24 @@ +import { useState } from "react"; +import { Card, Col, Container, Stack, Row, Tab, Tabs } from "react-bootstrap"; +import "./Home.scss"; +import UsersPage from "../UsersPage/UsersPage"; +import ArticlesPage from "../ArticlesPage/ArticlesPage"; + +export default function Home() { + return ( + + +

    + Welcome to the admin portal of CS Central. +

    +

    + You can use the header to manage users, articles, and more. +

    +
    +
    + ); +} diff --git a/admin-frontend/src/pages/HomePage/Home.scss b/admin-frontend/src/pages/HomePage/Home.scss new file mode 100644 index 0000000..b14765c --- /dev/null +++ b/admin-frontend/src/pages/HomePage/Home.scss @@ -0,0 +1,26 @@ +@import "../../scss/variables"; +@import "../../../node_modules/bootstrap/scss/bootstrap-grid.scss"; +@import "../../../node_modules/bootstrap/scss/grid"; +@import "../../../node_modules/bootstrap/scss/variables"; +@import "../../../node_modules//bootstrap/scss/mixins"; + +.nav-tabs { + height: 75px; +} + +.nav-item { + margin: 0px 5px; +} + +.nav-tabs .nav-link { + color: white; + background-color: #79a2d2; + opacity: 50%; +} + +.nav-tabs .nav-item .nav-link.active { + color: #fff !important; + background-color: #79a2d2 !important; + border-color: #007bff !important; + opacity: 100%; +} diff --git a/admin-frontend/src/pages/LoggedOutHome/LoggedOutHomePage.jsx b/admin-frontend/src/pages/LoggedOutHome/LoggedOutHomePage.jsx new file mode 100644 index 0000000..56e6256 --- /dev/null +++ b/admin-frontend/src/pages/LoggedOutHome/LoggedOutHomePage.jsx @@ -0,0 +1,22 @@ +import { Button, Container, Nav, Stack } from "react-bootstrap"; + +export default function LoggedOutHomePage() { + return ( + <> + + +

    + Welcome to the admin portal to CS Central. +

    + +
    +
    + + ); +} diff --git a/admin-frontend/src/pages/SignInPage/SignInPage.jsx b/admin-frontend/src/pages/SignInPage/SignInPage.jsx new file mode 100644 index 0000000..7120003 --- /dev/null +++ b/admin-frontend/src/pages/SignInPage/SignInPage.jsx @@ -0,0 +1,9 @@ +import SigninCard from "../../Components/SignIn/SigninCard"; + +export default function SigninPage() { + return ( + <> + + + ); +} diff --git a/admin-frontend/src/pages/UsersPage/UserDataPage.jsx b/admin-frontend/src/pages/UsersPage/UserDataPage.jsx new file mode 100644 index 0000000..91239b8 --- /dev/null +++ b/admin-frontend/src/pages/UsersPage/UserDataPage.jsx @@ -0,0 +1,69 @@ +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import { useGetUserData } from "../../hooks/useGetUserData.jsx"; +import UserData from "../../Components/UserData/UserData.jsx"; +import UserComments from "../../Components/UserData/UserComments.jsx"; +import { useGetCommentsByUser } from "../../hooks/useGetCommentsByUser.jsx"; +import { useDeleteComment } from "../../hooks/useDeleteComment"; + +export default function UserDataPage() { + const [user, setUser] = useState(); + const [comments, setComments] = useState(); + const params = useParams(); + const { getUserData } = useGetUserData(); + const { getCommentsByUser } = useGetCommentsByUser([]); + + const fetchUserComments = async () => { + try { + let fetchedComments = await getCommentsByUser(params.userID); + + if (fetchedComments?.comments) { + setComments(fetchedComments.comments); + } + } catch (err) { + console.log(err); + console.log("Error fetching user comments "); + } + }; + + useEffect(() => { + const fetchUserData = async () => { + try { + let fetchedUserData = await getUserData(params.userID); + + if (fetchedUserData) { + setUser(fetchedUserData); + } + } catch (err) { + console.log(err); + console.log("Error fetching user data "); + } + }; + + fetchUserData(); + fetchUserComments(); + }, [params.userID]); + + const { deleteComment } = useDeleteComment(); + + const handleDeleteComment = async (id) => { + try { + await deleteComment(id); + fetchUserComments(); + } catch (err) { + console.log(err); + console.log("Error fetching deleting comment "); + } + }; + + return ( + <> + {/*conditional rendering based on data being fetched*/} + {user && } +
    + {comments && ( + + )} + + ); +} diff --git a/admin-frontend/src/pages/UsersPage/UsersPage.jsx b/admin-frontend/src/pages/UsersPage/UsersPage.jsx new file mode 100644 index 0000000..0396bb3 --- /dev/null +++ b/admin-frontend/src/pages/UsersPage/UsersPage.jsx @@ -0,0 +1,56 @@ +import UserSearchBar from "../../Components/SearchBar/UserSearchBar"; +import UsersList from "../../Components/ListView/UsersList"; +import { useState, useEffect } from "react"; +import { useSearchUsers } from "../../hooks/useSearchUsers"; +import { useSearchParams } from "react-router-dom"; +import { useGetAdmins } from "../../hooks/useGetAdmins"; + +export default function UsersPage() { + const [users, setUsers] = useState([]); + const { searchUsers } = useSearchUsers(); + const { getAdmins } = useGetAdmins(); + const [searchParams, setSearchParams] = useSearchParams(); + + useEffect(() => { + const type = searchParams.get("type"); + + if (type === "admin") { + (async () => { + const results = await getAdmins(); + setUsers(results?.users || []); + })(); + } else { + const query = { + username: searchParams.get("username") || null, + first_name: searchParams.get("first_name") || null, + last_name: searchParams.get("last_name") || null, + email: searchParams.get("email") || null, + id: searchParams.get("id") ? parseInt(searchParams.get("id")) : null, + }; + + (async () => { + const results = await searchUsers(query); + setUsers(results?.users || []); + })(); + } + }, [searchParams]); + + const handleSearch = (results, paramsObject) => { + setUsers(results?.admins || results?.users || []); + + const cleanParams = {}; + for (const [key, value] of Object.entries(paramsObject)) { + if (value !== null && value !== "") { + cleanParams[key] = value; + } + } + setSearchParams(cleanParams); + }; + + return ( + <> + + + + ); +} diff --git a/admin-frontend/src/scss/_typeahead_override.scss b/admin-frontend/src/scss/_typeahead_override.scss new file mode 100644 index 0000000..7a0eab8 --- /dev/null +++ b/admin-frontend/src/scss/_typeahead_override.scss @@ -0,0 +1,58 @@ +@import "bootstrap/scss/bootstrap-grid.scss"; +@import "bootstrap/scss/grid"; +@import "bootstrap/scss/variables"; +@import "bootstrap/scss/mixins"; +@import "../scss/variables"; + +//typeahead override +//currently used only in settings and is styled only for it as a result + +/** + * Multi-select Input + */ +$rbt-background-color-disabled: #e9ecef !default; +$rbt-border-color-focus: #80bdff !default; +$rbt-border-color-focus: $input-focus-border-color; +$rbt-box-shadow-color: $focus-ring-color; +$rbt-background-color-disabled: $settings-input-bg-uneditable; +$rbt-background-color: #2d2626; + +.rbt-input-main { + &::placeholder { + color: black !important; + } +} + +.bg-editable-input { + > .rbt-input-multi { + background-color: $settings-input-bg-editable !important; + border-width: 0; + .rbt-input-main { + color: white !important; + } + } +} + +.bg-uneditable-input { + > .rbt-input-multi { + background-color: $settings-input-bg-uneditable !important; + border-width: 0; + .rbt-input-main { + color: gray !important; + } + } +} + +//typeahead token +$rbt-token-background-color: rgb(168, 211, 240); +$rbt-color-primary: black; +$rbt-token-color: black; +$rbt-color-disabled: #495057 !default; +$rbt-color-white: #fff !default; + +//then token is clicked on +$rbt-token-active-background-color: #afaeae; +$rbt-token-active-color: black; + +@import "react-bootstrap-typeahead/css/Typeahead.scss"; +@import "react-bootstrap-typeahead/css/Typeahead.bs5.scss"; diff --git a/admin-frontend/src/scss/_variables.scss b/admin-frontend/src/scss/_variables.scss new file mode 100644 index 0000000..e9a6088 --- /dev/null +++ b/admin-frontend/src/scss/_variables.scss @@ -0,0 +1,16 @@ + +$primary: #f65757; +$secondary: #228b22; + +$form-select-indicator-color: #FFFF; + +$dropdown-bg: #4a4848; +$dropdown-color: white; +$dropdown-link-color: white; +$dropdown-link-hover-bg: #8c8888; +$dropdown-link-disabled-color: #8c8888; + + +@import './variables/variables-dark'; +@import './variables/variables-light'; +@import './variables/variables-universal'; \ No newline at end of file diff --git a/admin-frontend/src/scss/custom.scss b/admin-frontend/src/scss/custom.scss new file mode 100644 index 0000000..462892a --- /dev/null +++ b/admin-frontend/src/scss/custom.scss @@ -0,0 +1,9 @@ +@import "./variables"; +@import "./typeahead_override"; + + +.form-control:valid { + background-image: none !important; +} + +@import "bootstrap/scss/bootstrap"; diff --git a/admin-frontend/src/scss/variables/_variables-dark.scss b/admin-frontend/src/scss/variables/_variables-dark.scss new file mode 100644 index 0000000..676f56a --- /dev/null +++ b/admin-frontend/src/scss/variables/_variables-dark.scss @@ -0,0 +1,20 @@ +//primary +$primary-bg-dark: #161515; +$primary-text-dark: #ffffff; + +//divider +$divider-dark: #ffffff; + +//bookmark +$bookmark-bg-dark: #f2c925; + +//article +$article-header-dark: #79a2d2; +$related-topic-link-dark: #094ed4; +$article-toc-desc-dark: #e7dede; + +//arrow marker +$arrow-marker-dark: #224267; + +//article editor +$article-editor-body-dark: #202020; diff --git a/admin-frontend/src/scss/variables/_variables-light.scss b/admin-frontend/src/scss/variables/_variables-light.scss new file mode 100644 index 0000000..e8002d6 --- /dev/null +++ b/admin-frontend/src/scss/variables/_variables-light.scss @@ -0,0 +1,20 @@ +//primary +$primary-bg-light: #ffffff; +$primary-text-light: #000000; + +//divider +$divider-light: #777777; + +//bookmark +$bookmark-bg-light: #d8c87e; + +//Article +$article-header-light: #224267; +$related-topic-link-light: #0f6fdf; +$article-toc-desc-light: #5d5c5c; + +//arrow +$arrow-marker-light: #79a2d2; + +//article editor +$article-editor-body-light: #eaeaea; diff --git a/admin-frontend/src/scss/variables/_variables-universal.scss b/admin-frontend/src/scss/variables/_variables-universal.scss new file mode 100644 index 0000000..84993d6 --- /dev/null +++ b/admin-frontend/src/scss/variables/_variables-universal.scss @@ -0,0 +1,52 @@ +$primary: #79a2d2; +$link-text: #2c6ae1; + +//header/footer +$header-footer-bg: #312f2f; +$header-text: #f4e4bb; + +//Related Topic Tags +$related-topic-tag-bg: #2c6ae1; + +//popover +$popover-header-bg: #063ba1; +$popover-body-bg: #2c6ae1; + +//settings +$settings-input-bg-uneditable: #d5d5d5; +$settings-input-placeholder-uneditable: #b9b9b9; +$settings-input-bg-editable: #7a7979; +$settings-input-placeholder-editable: #c2bfbf; + +$settings-article-search-bar: #d5d5d5; + +//Sign Pages +$sign-page-title: #366092; +$welcome-section-link: #4e8ad1; +$sign-input-placeholder: #6b6565; + +//fonts +@font-face { + font-family: "Fredericka The Great"; + src: local("Fredericka the Great"), + url("../../assets/fonts/Fredericka-The-Great/FrederickatheGreat-Regular.ttf") + format("truetype"); +} + +@font-face { + font-family: "Inter"; + src: local("Inter"), + url("../../assets/fonts/Inter/Inter-VariableFont_slnt\,wght.ttf") + format("truetype"); +} + +@font-face { + font-family: "Alumni_Sans"; + src: local("Alumni_Sans"), + url("../../assets/fonts/Alumni_Sans/AlumniSans-VariableFont_wght.ttf") + format("truetype"); +} + +$font-family-sans-serif: Inter, system-ui, -apple-system, "Segoe UI", Roboto, + "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; diff --git a/admin-frontend/vite.config.js b/admin-frontend/vite.config.js new file mode 100644 index 0000000..a19c258 --- /dev/null +++ b/admin-frontend/vite.config.js @@ -0,0 +1,20 @@ +import { defineConfig, loadEnv } from "vite"; +import react from "@vitejs/plugin-react"; + +export default ({ mode }) => { + // Load environment variables based on the current mode + const env = loadEnv(mode, process.cwd()); + + return defineConfig({ + plugins: [react()], + define: { + // Make sure to stringify the environment variables + "process.env": { + VITE_API_URL: JSON.stringify(env.VITE_API_URL), + }, + }, + server: { + port: 3001, + }, + }); +}; diff --git a/frontend/index.html b/frontend/index.html index 8bd5d8e..d90d777 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -22,7 +22,7 @@ href="https://fonts.googleapis.com/css2?family=Roboto&display=swap" rel="stylesheet" /> - CS Catalog + CS Central diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 524d8bb..b59ada2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,15 +8,26 @@ "name": "frontend", "version": "0.1.0", "dependencies": { + "@editorjs/editorjs": "^2.30.2", + "@editorjs/header": "^2.8.7", + "@editorjs/list": "^1.10.0", + "@editorjs/paragraph": "^2.11.6", + "@editorjs/simple-image": "^1.6.0", + "@sotaproject/strikethrough": "^1.0.1", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "bootstrap": "^5.3.2", + "dompurify": "^3.2.6", + "editorjs-drag-drop": "^1.1.14", + "editorjs-undo": "^2.0.28", + "html-react-parser": "^5.2.5", "react": "^18.2.0", "react-bootstrap": "^2.9.1", "react-bootstrap-icons": "^1.10.3", "react-bootstrap-typeahead": "^6.3.2", "react-dom": "^18.2.0", + "react-hot-toast": "^2.5.2", "react-router-dom": "^6.18.0", "web-vitals": "^2.1.4" }, @@ -528,6 +539,62 @@ "node": ">=6.9.0" } }, +<<<<<<< HEAD + "node_modules/@codexteam/icons": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@codexteam/icons/-/icons-0.0.4.tgz", + "integrity": "sha512-V8N/TY2TGyas4wLrPIFq7bcow68b3gu8DfDt1+rrHPtXxcexadKauRJL6eQgfG7Z0LCrN4boLRawR4S9gjIh/Q==" + }, + "node_modules/@editorjs/editorjs": { + "version": "2.30.2", + "resolved": "https://registry.npmjs.org/@editorjs/editorjs/-/editorjs-2.30.2.tgz", + "integrity": "sha512-JjtUDs2/aHTEjNZzEf/2cugpIli1+aNeU8mloOd5USbVxv2vC02HTMpv7Vc1UyB7dIuc45JaYSJwgnBZp9duhA==" + }, + "node_modules/@editorjs/header": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/@editorjs/header/-/header-2.8.7.tgz", + "integrity": "sha512-rfxzYFR/Jhaocj3Xxx8XjEjyzfPbBIVkcPZ9Uy3rEz1n3ewhV0V4zwuxCjVfFhLUVgQQExq43BxJnTNlLOzqDQ==", + "dependencies": { + "@codexteam/icons": "^0.0.5", + "@editorjs/editorjs": "^2.29.1" + } + }, + "node_modules/@editorjs/header/node_modules/@codexteam/icons": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@codexteam/icons/-/icons-0.0.5.tgz", + "integrity": "sha512-s6H2KXhLz2rgbMZSkRm8dsMJvyUNZsEjxobBEg9ztdrb1B2H3pEzY6iTwI4XUPJWJ3c3qRKwV4TrO3J5jUdoQA==" + }, + "node_modules/@editorjs/list": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@editorjs/list/-/list-1.10.0.tgz", + "integrity": "sha512-zXCHaNcIscpefnteBOS3x+98f/qBgEVsv+OvtKoTDZipMNqck2uVG+X2qMQr8xcwtJrj9ySX54lUac9FDlAHnA==", + "dependencies": { + "@codexteam/icons": "^0.0.4" + } + }, + "node_modules/@editorjs/paragraph": { + "version": "2.11.6", + "resolved": "https://registry.npmjs.org/@editorjs/paragraph/-/paragraph-2.11.6.tgz", + "integrity": "sha512-i9B50Ylvh+0ZzUGWIba09PfUXsA00Y//zFZMwqsyaXXKxMluSIJ6ADFJbbK0zaV9Ijx49Xocrlg+CEPRqATk9w==", + "dependencies": { + "@codexteam/icons": "^0.0.4" + } + }, + "node_modules/@editorjs/simple-image": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@editorjs/simple-image/-/simple-image-1.6.0.tgz", + "integrity": "sha512-WvdGfQPlozwZd3PXQrJnRXk6gEYbv1U2vRupYJ6lTd3/UsLInXYUX5jSFcnGB5ZMH3bd0JDZfcb4d4Sv1/1big==", + "dependencies": { + "@codexteam/icons": "^0.0.6" + } + }, + "node_modules/@editorjs/simple-image/node_modules/@codexteam/icons": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@codexteam/icons/-/icons-0.0.6.tgz", + "integrity": "sha512-L7Q5PET8PjKcBT5wp7VR+FCjwCi5PUp7rd/XjsgQ0CI5FJz0DphyHGRILMuDUdCW2MQT9NHbVr4QP31vwAkS/A==" + }, +======= +>>>>>>> main "node_modules/@esbuild/aix-ppc64": { "version": "0.19.11", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.11.tgz", @@ -1214,6 +1281,22 @@ "win32" ] }, +<<<<<<< HEAD + "node_modules/@sotaproject/strikethrough": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@sotaproject/strikethrough/-/strikethrough-1.0.1.tgz", + "integrity": "sha512-tgfYXrg+lY6lCh2ZXyoPfe8rxpe+orRgiI2mOSyu1z/+gb9xIjOGhIXNgC6ehOmZUzqtoWzi9SrIEALCYfQpjA==", + "dependencies": { + "@codexteam/icons": "^0.0.8" + } + }, + "node_modules/@sotaproject/strikethrough/node_modules/@codexteam/icons": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@codexteam/icons/-/icons-0.0.8.tgz", + "integrity": "sha512-toTLbLPbrDFl2ZK2mtDC+P7sdrJaqOK6URsjFy6m54Pry8q4ckKeKJ1fOEwVcSGKiwdRhrRO3ZzSbL16DaIBJA==" + }, +======= +>>>>>>> main "node_modules/@swc/helpers": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.3.tgz", @@ -1890,6 +1973,15 @@ "@types/jest": "*" } }, +<<<<<<< HEAD + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "optional": true + }, +======= +>>>>>>> main "node_modules/@types/warning": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.2.tgz", @@ -2225,9 +2317,15 @@ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==" }, "node_modules/csstype": { +<<<<<<< HEAD + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" +======= "version": "3.1.2", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" +>>>>>>> main }, "node_modules/debug": { "version": "4.3.4", @@ -2325,12 +2423,112 @@ "csstype": "^3.0.2" } }, +<<<<<<< HEAD + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/dompurify": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz", + "integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/editorjs-drag-drop": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/editorjs-drag-drop/-/editorjs-drag-drop-1.1.14.tgz", + "integrity": "sha512-FpH3ILtfsadWrb/VcRfET7kC2fK3cBnlrTBY4wjm1bLNSxpM/OSmaRToeJJMxaIuwMldkKyuG+qWP4CiOLUu3g==" + }, + "node_modules/editorjs-undo": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/editorjs-undo/-/editorjs-undo-2.0.28.tgz", + "integrity": "sha512-7MiLdjIsPOXn8+cHDeAt2HapVklg4vcLWWkd2eMMlGI6UVtwYSjdQquDSv9752B/fOEP1C3NMF2sdiCoZV3tTA==", + "dependencies": { + "vanilla-caret-js": "^1.0.1" + } + }, +======= +>>>>>>> main "node_modules/electron-to-chromium": { "version": "1.4.623", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.623.tgz", "integrity": "sha512-lKoz10iCYlP1WtRYdh5MvocQPWVRoI7ysp6qf18bmeBgR8abE6+I2CsfyNKztRDZvhdWc+krKT6wS7Neg8sw3A==", "dev": true }, +<<<<<<< HEAD + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, +======= +>>>>>>> main "node_modules/es-get-iterator": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", @@ -2491,6 +2689,17 @@ "node": ">=4" } }, +<<<<<<< HEAD + "node_modules/goober": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz", + "integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, +======= +>>>>>>> main "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -2581,6 +2790,56 @@ "node": ">= 0.4" } }, +<<<<<<< HEAD + "node_modules/html-dom-parser": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/html-dom-parser/-/html-dom-parser-5.1.1.tgz", + "integrity": "sha512-+o4Y4Z0CLuyemeccvGN4bAO20aauB2N9tFEAep5x4OW34kV4PTarBHm6RL02afYt2BMKcr0D2Agep8S3nJPIBg==", + "dependencies": { + "domhandler": "5.0.3", + "htmlparser2": "10.0.0" + } + }, + "node_modules/html-react-parser": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/html-react-parser/-/html-react-parser-5.2.5.tgz", + "integrity": "sha512-bRPdv8KTqG9CEQPMNGksDqmbiRfVQeOidry8pVetdh/1jQ1Edx4KX5m0lWvDD89Pt4CqTYjK1BLz6NoNVxN/Uw==", + "dependencies": { + "domhandler": "5.0.3", + "html-dom-parser": "5.1.1", + "react-property": "2.0.2", + "style-to-js": "1.1.16" + }, + "peerDependencies": { + "@types/react": "0.14 || 15 || 16 || 17 || 18 || 19", + "react": "0.14 || 15 || 16 || 17 || 18 || 19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/htmlparser2": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", + "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.1", + "entities": "^6.0.0" + } + }, +======= +>>>>>>> main "node_modules/immutable": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.4.tgz", @@ -2597,6 +2856,14 @@ "node": ">=8" } }, +<<<<<<< HEAD + "node_modules/inline-style-parser": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", + "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==" + }, +======= +>>>>>>> main "node_modules/internal-slot": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.6.tgz", @@ -3246,6 +3513,25 @@ "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==" }, +<<<<<<< HEAD + "node_modules/react-hot-toast": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.5.2.tgz", + "integrity": "sha512-Tun3BbCxzmXXM7C+NI4qiv6lT0uwGh4oAfeJyNOjYUejTsm35mK9iCaYLGv8cBz9L5YxZLx/2ii7zsIwPtPUdw==", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, +======= +>>>>>>> main "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -3289,6 +3575,14 @@ "react-dom": "^16.8.0 || ^17 || ^18" } }, +<<<<<<< HEAD + "node_modules/react-property": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/react-property/-/react-property-2.0.2.tgz", + "integrity": "sha512-+PbtI3VuDV0l6CleQMsx2gtK0JZbZKbpdu5ynr+lbsuvtmgbNcS3VM0tuY2QjFNOcWxvXeHjDpy42RO+4U2rug==" + }, +======= +>>>>>>> main "node_modules/react-router": { "version": "6.18.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.18.0.tgz", @@ -3537,6 +3831,25 @@ "node": ">=8" } }, +<<<<<<< HEAD + "node_modules/style-to-js": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.16.tgz", + "integrity": "sha512-/Q6ld50hKYPH3d/r6nr117TZkHR0w0kGGIVfpG9N6D8NymRPM9RqCUv4pRpJ62E5DqOYx2AFpbZMyCPnjQCnOw==", + "dependencies": { + "style-to-object": "1.0.8" + } + }, + "node_modules/style-to-object": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.8.tgz", + "integrity": "sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==", + "dependencies": { + "inline-style-parser": "0.2.4" + } + }, +======= +>>>>>>> main "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -3650,6 +3963,14 @@ "browserslist": ">= 4.21.0" } }, +<<<<<<< HEAD + "node_modules/vanilla-caret-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/vanilla-caret-js/-/vanilla-caret-js-1.1.0.tgz", + "integrity": "sha512-vl3R4Xjqb+xnM0gYyg+wcqWGYwKKnkz58Yj29/FQB+w+yP7R8IR1DGoJnDIs05moEZiGlQUTabPMoEtnQNcrpQ==" + }, +======= +>>>>>>> main "node_modules/vite": { "version": "5.0.11", "resolved": "https://registry.npmjs.org/vite/-/vite-5.0.11.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1f53551..4bad5ac 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,15 +3,26 @@ "version": "0.1.0", "private": true, "dependencies": { + "@editorjs/editorjs": "^2.30.2", + "@editorjs/header": "^2.8.7", + "@editorjs/list": "^1.10.0", + "@editorjs/paragraph": "^2.11.6", + "@editorjs/simple-image": "^1.6.0", + "@sotaproject/strikethrough": "^1.0.1", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "bootstrap": "^5.3.2", + "dompurify": "^3.2.6", + "editorjs-drag-drop": "^1.1.14", + "editorjs-undo": "^2.0.28", + "html-react-parser": "^5.2.5", "react": "^18.2.0", "react-bootstrap": "^2.9.1", "react-bootstrap-icons": "^1.10.3", "react-bootstrap-typeahead": "^6.3.2", "react-dom": "^18.2.0", + "react-hot-toast": "^2.5.2", "react-router-dom": "^6.18.0", "web-vitals": "^2.1.4" }, diff --git a/frontend/public/cc_logo.png b/frontend/public/cc_logo.png new file mode 100644 index 0000000..6b03524 Binary files /dev/null and b/frontend/public/cc_logo.png differ diff --git a/frontend/public/cc_logo_white.png b/frontend/public/cc_logo_white.png new file mode 100644 index 0000000..4e6cd51 Binary files /dev/null and b/frontend/public/cc_logo_white.png differ diff --git a/frontend/public/default_avatar.jpg b/frontend/public/default_avatar.jpg new file mode 100644 index 0000000..3fe2058 Binary files /dev/null and b/frontend/public/default_avatar.jpg differ diff --git a/frontend/public/logo_black.png b/frontend/public/logo_black.png new file mode 100644 index 0000000..8a242f1 Binary files /dev/null and b/frontend/public/logo_black.png differ diff --git a/frontend/public/logo_white.png b/frontend/public/logo_white.png new file mode 100644 index 0000000..ba75f6e Binary files /dev/null and b/frontend/public/logo_white.png differ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7c6a6bd..c7c1017 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,7 +1,7 @@ -import { Route, Routes } from "react-router-dom"; +import { Route, Routes, Navigate, Outlet } from "react-router-dom"; -import Home from "./pages/Home.jsx"; -import ArticleList from "./pages/ArticleList.jsx"; +import Home from "./pages/HomePage/Home.jsx"; +import ArticleResultsPage from "./pages/ArticleResultsPage/ArticleResultsPage.jsx"; import ArticleView from "./pages/ArticleView.jsx"; import Signup from "./pages/SignPages/Signup.jsx"; import Signin from "./pages/SignPages/Signin.jsx"; @@ -12,8 +12,14 @@ import ProfileEdit from "./Components/Settings/Profile/ProfileEdit.jsx"; import SavedArticles from "./Components/Settings/SavedArticles/SavedArticles.jsx"; import CustomizationsEdit from "./Components/Settings/Customizations/CustomizationsEdit.jsx"; import SignWelcome from "./pages/SignPages/SignWelcome.jsx"; - +import ArticleEditorPage from "./pages/ArticleEditorPage/ArticleEditorPage.jsx"; +import MyArticlesPage from "./pages/MyArticlesPage/MyArticlesPage.jsx"; +import toast, { Toaster } from "react-hot-toast"; +import { useLoadingSpinner } from "./context/SpinnerContext.jsx"; +import LoadingSpinner from "./Components/LoadingSpinner/LoadingSpinner.jsx"; function App() { + const { spinnerIsShowing } = useLoadingSpinner(); + return ( <> @@ -23,13 +29,19 @@ function App() { } /> } /> - } /> - } /> + } /> + } /> } /> - } /> + } /> + } /> + } + /> + } /> }> } /> } /> @@ -42,6 +54,8 @@ function App() { /> + {spinnerIsShowing && } + ); } diff --git a/frontend/src/Components/Article/Article.jsx b/frontend/src/Components/Article/Article.jsx index 88d3b37..fb620e6 100644 --- a/frontend/src/Components/Article/Article.jsx +++ b/frontend/src/Components/Article/Article.jsx @@ -6,19 +6,14 @@ import BodySection from "./Section/BodySection.jsx"; import { Container, Row, Col, Stack } from "react-bootstrap"; import "./ArticleComponents.scss"; import "./Article.scss"; -import { useState } from "react"; -//dummy data for table of contents -const tableOfContents = [ - "Introduction to Machine Learning", - "The Fundamentals of Machine Learning", - "Types of Machine Learning Algorithms", - "Real-World Applications of Machine Learning", - "Challenges and Limitations in Machine Learning", - "Ethical Considerations in Machine Learning", - "Future Prospects and Developments in Machine Learning", - "Conclusion and Key Takeaways", -]; - +import { useEffect, useState, useRef } from "react"; +import { useToggleBookmark } from "../../hooks/useToggleBookmark.jsx"; +import { useAuthContext } from "../../hooks/useAuthContext.jsx"; +import { useLoadingSpinner } from "../../context/SpinnerContext.jsx"; +import toast from "react-hot-toast"; +import Comments from "./Comments/Comments.jsx"; +import { useLikeArticle } from "../../hooks/ArticleLikes/useLikeArticle.jsx"; +import { useUnlikeArticle } from "../../hooks/ArticleLikes/useUnlikeArticle.jsx"; //dummy data for related topics list const relatedTopicsList = [ { @@ -74,61 +69,152 @@ const relatedTopicsList = [ //this component accepts an article object and displays the corresponding article export default function Article({ article }) { + const { user } = useAuthContext(); + const { toggleBookmark } = useToggleBookmark(); + const { showSpinner, hideSpinner } = useLoadingSpinner(); //extracts article data pieces from provided article - //these properties that i'm defining aside from title don't exist in the database yet, - //mostly made up so feel free to change later on - - //will display default data from figma for now + let titleBlocks = article.header.blocks; + let descriptionBlocks = article.description.blocks; const [articleData, setArticleData] = useState({ - //header data - title: article.title ? article.title : "Intro to Machine Learning", - desc: article.desc - ? article.desc - : "Embark on a journey through the basics; explore what machine learning entails and how one can apply it in the real world.", - author: article.author ? article.author : "David Lam", - date: article.date ? article.date : "October 29, 2023", - isBookmarked: false, + ...article, }); - //handler for toggling bookmark - const toggleBookmark = () => - setArticleData({ ...articleData, isBookmarked: !articleData.isBookmarked }); + let contentSequence = []; + + for (const sectionIndex in articleData.articleBody) { + const section = articleData.articleBody[sectionIndex]; + if (section.blocks.length > 0 && section.blocks[0].type === "header") { + contentSequence.push({ + heading: section.blocks[0], + link: `#${section.id}`, + }); + } else { + contentSequence.push({ + heading: { + type: "header", + data: { text: `Section ${Number(sectionIndex) + 1}` }, + }, + link: `#${section.id}`, + }); + } + } + + //handler for toggling bookmark depending on server response + const toggleBookmarkHandler = async () => { + if (!user) { + toast.error("You must be logged in to bookmark this article."); + return; + } + showSpinner(); + await ((ms) => new Promise((resolve) => setTimeout(resolve, ms)))(250); + let result = toggleBookmark(articleData.id, articleData.isBookmarked); + + if (!result.error) { + setArticleData({ + ...articleData, + isBookmarked: !articleData.isBookmarked, + }); + } + hideSpinner(); + }; + + //handler for toggling like/unlike + const { likeArticle } = useLikeArticle(); + const { unlikeArticle } = useUnlikeArticle(); + const [isLiked, setIsLiked] = useState(article.isLiked || false); + const [likeCount, setLikeCount] = useState(article.like_count || 0); + + const toggleLikeHandler = async () => { + if (!user) { + toast.error("You must be logged in to like this article."); + return; + } + const articleId = article.id; + const { success, response } = isLiked + ? await unlikeArticle({ articleId }) + : await likeArticle({ articleId }); + + if (success) { + setIsLiked(!isLiked); + setLikeCount(response.like_count); + } + }; + + //handles scrolling down to comment section + const commentsRef = useRef(); + + const scrollToComments = () => { + commentsRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); + }; + + const [commentCount, setCommentCount] = useState(article.comment_count || 0); + return ( <> - - - - - - - - - - - - + + + - - + + + + + + + + + + {articleData.articleBody.map((bodySection, index) => { + //assumes that if there is a title, it will be the first block + + let currentBodySectionBlocks = [...bodySection.blocks]; + + //if the first block isn't a header, it will insert a dummy header + if ( + bodySection.blocks.length > 0 && + bodySection.blocks[0].type !== "header" + ) { + currentBodySectionBlocks.splice(0, 0, { + type: "header", + data: { text: `Section ${index + 1}`, level: 2 }, + }); + } + return ( + + ); + })} + + - - - - + + + + + +
    + ); diff --git a/frontend/src/Components/Article/ArticleComponents.scss b/frontend/src/Components/Article/ArticleComponents.scss index 73ab3fe..f411ee6 100644 --- a/frontend/src/Components/Article/ArticleComponents.scss +++ b/frontend/src/Components/Article/ArticleComponents.scss @@ -3,6 +3,11 @@ @import "bootstrap/scss/grid"; @import "bootstrap/scss/variables"; @import "bootstrap/scss/mixins"; +@import "../../scss/variables"; + +.article-container { + max-width: 1500px; +} //SectionHeader, ArticleHeaderDesc marker styling .section-header-marker, @@ -47,9 +52,10 @@ } } -.article-bookmark { - margin-left: 10px; - font-size: calc(0.8rem + 0.3vw); +.article-bookmark, +.article-comment-icon, +.article-likes-icon { + font-size: calc(1rem + 0.5vw + 0.5vh); &:hover { cursor: pointer; @@ -83,6 +89,24 @@ flex-shrink: 0; } +//Article Body Section highlight styling +@keyframes article-highlight { + 0% { + background-color: inherit; + } + + 50% { + background-color: $primary; + } + + 100% { + background-color: inherit; + } +} +.article-body-section:target { + animation: article-highlight 700ms 1; +} + //SectionContent .section-content-marker-container { display: flex; @@ -144,6 +168,8 @@ } .section-content-text { + margin: 0; + margin-bottom: 0.2rem; width: calc(100% - 0.7rem); font-size: calc(1rem + 0.1vw); @@ -154,11 +180,15 @@ @media screen and (max-width: 500px) { font-size: calc(0.7rem + 0.3vw); } + + &:hover { + cursor: pointer; + } } //Related Topics List .related-topics-heading { - font-size: calc(0.975rem + 0.1vw); + font-size: calc(0.975rem + 0.2vw); line-height: 2rem; font-weight: 600; } @@ -166,7 +196,7 @@ //Topics List .topic-list-title, .topic-link { - font-size: calc(0.65rem + 0.1vw); + font-size: calc(0.65rem + 0.2vw); font-weight: 600; } @@ -190,6 +220,10 @@ max-height: 400px; min-height: 300px; + &:hover { + cursor: pointer; + } + @media screen and (max-width: 800px) { min-height: 200px; max-height: 300px; @@ -206,7 +240,11 @@ .table-contents-marker { background: $arrow-marker-light; } - .article-title, + + .article-title { + color: $article-header-light; + } + .related-topics-heading, .topic-list-title, .section-header, @@ -224,6 +262,12 @@ .article-bookmark { color: $bookmark-bg-light; } + .article-likes-icon { + color: $like-bg-light; + } + .article-comment-icon { + color: $comment-bg-light; + } } @include color-mode(dark) { @@ -232,7 +276,10 @@ background: $arrow-marker-dark; } - .article-title, + .article-title { + color: $article-header-dark; + } + .related-topics-heading, .topic-list-title, .section-header, @@ -249,4 +296,10 @@ .article-bookmark { color: $bookmark-bg-dark; } + .article-likes-icon { + color: $like-bg-dark; + } + .article-comment-icon { + color: $comment-bg-dark; + } } diff --git a/frontend/src/Components/Article/ArticleHeader/ArticleHeader.jsx b/frontend/src/Components/Article/ArticleHeader/ArticleHeader.jsx index f3cfdc6..a4d31b4 100644 --- a/frontend/src/Components/Article/ArticleHeader/ArticleHeader.jsx +++ b/frontend/src/Components/Article/ArticleHeader/ArticleHeader.jsx @@ -2,13 +2,21 @@ import { Stack } from "react-bootstrap"; import ArticleHeaderDesc from "./ArticleHeaderDesc"; import ArticleHeaderTitle from "./ArticleHeaderTitle"; import ArticleHeaderAuthorDate from "./ArticleHeaderAuthorDate"; +import { + Bookmark, + BookmarkFill, + Heart, + HeartFill, + ChatSquareText, +} from "react-bootstrap-icons"; //Component for the article header /* takes in: -string representing article title, -string representing article description, + +json block containing article title, +json block containing article description, string of author's name, string containing date of creation (for now this is simply normal data like "Oct 9, 2023", @@ -18,23 +26,57 @@ string containing date of creation */ export default function ArticleHeader({ - title, - description, + titleBlocks, + descriptionBlocks, author, date, isBookmarked, bookmarkToggler, + disableBookmark: disableArticleActions, + isLiked, + likeToggler, + likeCount, + onCommentIconClick, + commentCount, }) { return ( - - - + +
    + + + {!disableArticleActions && ( +
    + {isBookmarked ? ( + + ) : ( + + )} + {isLiked ? ( + + ) : ( + + )} + {likeCount} + + {commentCount} +
    + )} +
    ); } diff --git a/frontend/src/Components/Article/ArticleHeader/ArticleHeaderAuthorDate.jsx b/frontend/src/Components/Article/ArticleHeader/ArticleHeaderAuthorDate.jsx index 156969f..b1c2304 100644 --- a/frontend/src/Components/Article/ArticleHeader/ArticleHeaderAuthorDate.jsx +++ b/frontend/src/Components/Article/ArticleHeader/ArticleHeaderAuthorDate.jsx @@ -1,26 +1,12 @@ -import { Bookmark, BookmarkFill } from "react-bootstrap-icons"; -//component for article author and date, also contains bookmark -export default function ArticleHeaderAuthorDate({ - author, - date, - isBookmarked, - bookmarkToggler, -}) { +//component for article author and date +export default function ArticleHeaderAuthorDate({ author, date }) { return ( -
    +
    By {author}
    Published {date} - {isBookmarked ? ( - - ) : ( - - )} -
    +
    {" "}
    ); } diff --git a/frontend/src/Components/Article/ArticleHeader/ArticleHeaderDesc.jsx b/frontend/src/Components/Article/ArticleHeader/ArticleHeaderDesc.jsx index 56bfd77..4965a95 100644 --- a/frontend/src/Components/Article/ArticleHeader/ArticleHeaderDesc.jsx +++ b/frontend/src/Components/Article/ArticleHeader/ArticleHeaderDesc.jsx @@ -1,14 +1,17 @@ import { Stack } from "react-bootstrap"; import ArrowMarker from "../../ArrowMarker/ArrowMarker"; +import BlocksParser from "../../BlocksParser/BlocksParser"; //component for author description -export default function ArticleHeaderDesc({ desc }) { +export default function ArticleHeaderDesc({ descriptionBlocks }) { return (
    -

    {desc}

    +

    + +

    ); diff --git a/frontend/src/Components/Article/ArticleHeader/ArticleHeaderTitle.jsx b/frontend/src/Components/Article/ArticleHeader/ArticleHeaderTitle.jsx index a733bfe..4233d5c 100644 --- a/frontend/src/Components/Article/ArticleHeader/ArticleHeaderTitle.jsx +++ b/frontend/src/Components/Article/ArticleHeader/ArticleHeaderTitle.jsx @@ -1,10 +1,30 @@ import { Stack } from "react-bootstrap"; +import { Bookmark, BookmarkFill } from "react-bootstrap-icons"; +import BlocksParser from "../../BlocksParser/BlocksParser"; + //component for article title -export default function ArticleHeaderTitle({ title }) { +export default function ArticleHeaderTitle({ + titleBlocks, + isBookmarked, + bookmarkToggler, + disableBookmark, +}) { return ( - -

    {title}

    + + {!disableBookmark && + (isBookmarked ? ( + + ) : ( + + ))} + +

    + +

    ); } diff --git a/frontend/src/Components/Article/ArticleImage.jsx b/frontend/src/Components/Article/ArticleImage.jsx index 19fd231..7619851 100644 --- a/frontend/src/Components/Article/ArticleImage.jsx +++ b/frontend/src/Components/Article/ArticleImage.jsx @@ -1,5 +1,19 @@ +import { useState } from "react"; import { Image } from "react-bootstrap"; +import ArticleImagePreview from "../../Components/Article/ArticleImagePreview/ArticleImagePreview"; //Article Image export default function ArticleImage({ image, alt_text }) { - return {alt_text}; + const [show, setShow] = useState(false); + return ( + <> + + {alt_text} setShow(true)} + /> + + ); } diff --git a/frontend/src/Components/Article/ArticleImagePreview/ArticleImagePreview.jsx b/frontend/src/Components/Article/ArticleImagePreview/ArticleImagePreview.jsx new file mode 100644 index 0000000..0203555 --- /dev/null +++ b/frontend/src/Components/Article/ArticleImagePreview/ArticleImagePreview.jsx @@ -0,0 +1,26 @@ +import { Modal, Image } from "react-bootstrap"; +import "./ArticleImagePreview.scss"; + +export default function ArticleImagePreview({ + imageSrc, + show, + setShow, + caption, +}) { + return ( + setShow(false)} + className="preview-image-modal" + centered + > + + Image Preview + + + + + {caption && {caption}} + + ); +} diff --git a/frontend/src/Components/Article/ArticleImagePreview/ArticleImagePreview.scss b/frontend/src/Components/Article/ArticleImagePreview/ArticleImagePreview.scss new file mode 100644 index 0000000..c33494d --- /dev/null +++ b/frontend/src/Components/Article/ArticleImagePreview/ArticleImagePreview.scss @@ -0,0 +1,16 @@ +.preview-image-modal { + .modal-dialog { + width: auto; + max-width: none; + } + + .modal-content { + width: fit-content; + height: fit-content; + margin: auto; + } + + .modal-footer { + justify-content: unset; + } +} diff --git a/frontend/src/Components/Article/Comments/CommentCreation.jsx b/frontend/src/Components/Article/Comments/CommentCreation.jsx new file mode 100644 index 0000000..4b80f47 --- /dev/null +++ b/frontend/src/Components/Article/Comments/CommentCreation.jsx @@ -0,0 +1,76 @@ +import { + Container, + Form, + Card, + Col, + Row, + Image, + Button, +} from "react-bootstrap"; +import { useCommentCreate } from "../../../hooks/Comments/useCommentCreate"; +import { useAuthContext } from "../../../hooks/useAuthContext"; +import { useParams } from "react-router-dom"; +import { useState } from "react"; + +const DEFAULT_AVATAR = "/default_avatar.jpg"; + +export default function CommentCreation({ onCommentPosted }) { + const { user } = useAuthContext(); + const { articleID = "" } = useParams(); + const [commentContent, setCommentContent] = useState(); + const { createComment } = useCommentCreate(); + + const handleSubmit = async (e) => { + e.preventDefault(); + console.log("Submitting"); + if (!commentContent.trim()) return; + + try { + await createComment({ + articleId: articleID, + commentData: { content: commentContent }, + }); + + setCommentContent(""); + + if (onCommentPosted) { + onCommentPosted(); + } + } catch (err) { + console.log("Failed to post comment."); + } + }; + + return ( + + +
    + + + + + + setCommentContent(e.target.value)} + /> + + + + + + + +
    +
    +
    + ); +} diff --git a/frontend/src/Components/Article/Comments/Comments.jsx b/frontend/src/Components/Article/Comments/Comments.jsx new file mode 100644 index 0000000..6f49f02 --- /dev/null +++ b/frontend/src/Components/Article/Comments/Comments.jsx @@ -0,0 +1,113 @@ +import CommentCreation from "./CommentCreation"; +import CommentsList from "./CommentsList"; +import { useEffect, useState } from "react"; +import { useGetCommentsByArticles } from "../../../hooks/Comments/useGetCommentsByArticle"; +import { useParams } from "react-router-dom"; +import "./Comments.scss"; +import { useCommentDelete } from "../../../hooks/Comments/useCommentDelete"; +import { useAuthContext } from "../../../hooks/useAuthContext"; +import { Row, Col, Dropdown, Container } from "react-bootstrap"; +import { ChevronDown } from "react-bootstrap-icons"; + +export default function Comments({ setCommentCount }) { + const { articleID = "" } = useParams(); + const { getCommentsByArticle } = useGetCommentsByArticles(); + const [comments, setComments] = useState([]); + const { user } = useAuthContext(); + + const fetchComments = async () => { + try { + const result = await getCommentsByArticle(articleID); + if (result?.comments) { + const sortedComments = sortComments(result.comments); + setComments(sortedComments); + setCommentCount(result.comments.length); + } else { + setComments([]); + } + } catch (err) { + console.error("Error fetching comments:", err); + } + }; + + const { deleteComment } = useCommentDelete(); + + const handleDeleteComment = async (id) => { + try { + await deleteComment(id); + fetchComments(); + } catch (err) { + console.log(err); + console.log("Error deleting comment "); + } + }; + + const [sortBy, setSortBy] = useState("Newest"); + + const sortComments = (commentsToSort) => { + const sortedComments = [...commentsToSort].sort((a, b) => { + const dateA = new Date(a.created_at); + const dateB = new Date(b.created_at); + return sortBy === "Newest" ? dateB - dateA : dateA - dateB; + }); + return sortedComments; + }; + + useEffect(() => { + fetchComments(); + }, [articleID]); + + useEffect(() => { + setComments((prev) => sortComments(prev)); + }, [sortBy]); + + return ( + <> + + + +

    Comments

    + + + + + {sortBy} + + + setSortBy("Newest")}> + Newest + + setSortBy("Oldest")}> + Oldest + + + + +
    + {!user && ( +
    +

    + Log in or sign up to post your own comment! +

    + +
    + )} +
    + {user && ( + + )} + + + ); +} diff --git a/frontend/src/Components/Article/Comments/Comments.scss b/frontend/src/Components/Article/Comments/Comments.scss new file mode 100644 index 0000000..c55d084 --- /dev/null +++ b/frontend/src/Components/Article/Comments/Comments.scss @@ -0,0 +1,44 @@ +@import "../../../scss/variables"; +@import "bootstrap/scss/bootstrap-grid.scss"; +@import "bootstrap/scss/grid"; +@import "bootstrap/scss/variables"; +@import "bootstrap/scss/mixins"; +@import "../../../scss/variables"; + +.comments-container, +.comment-creation, +.comments-list { + max-width: 1500px; +} + +.dropdown-toggle::after { + display: none !important; +} + +@include color-mode(light) { + .item-card { + background-color: $article-editor-body-light; + } + hr { + background-color: $divider-light; + height: 1px; + opacity: 100%; + border: 0; + } +} + +@include color-mode(dark) { + .item-card { + background-color: $article-editor-body-dark; + } + hr { + background-color: $divider-dark; + height: 1px; + opacity: 100%; + border: 0; + } +} + +.scroll-anchor { + scroll-margin-top: 120px; +} diff --git a/frontend/src/Components/Article/Comments/CommentsList.jsx b/frontend/src/Components/Article/Comments/CommentsList.jsx new file mode 100644 index 0000000..2da9a8a --- /dev/null +++ b/frontend/src/Components/Article/Comments/CommentsList.jsx @@ -0,0 +1,68 @@ +import { + Stack, + Card, + Row, + Col, + Container, + Image, + Dropdown, +} from "react-bootstrap"; +import { useAuthContext } from "../../../hooks/useAuthContext"; +import { ThreeDotsVertical } from "react-bootstrap-icons"; + +const DEFAULT_AVATAR = "/default_avatar.jpg"; + +export default function CommentsList({ comments, onDelete }) { + const { user } = useAuthContext(); + + return ( + + {comments.length === 0 ? ( +

    + No comments found. Be the first and post your own! +

    + ) : ( + + {comments.map((comment) => ( + + + + + + +
    + {comment.name}{" "} + + @{comment.username} -{" "} + {new Date(comment.created_at).toLocaleString()} + +
    +
    {comment.content}
    + + {user?.username === comment.username && ( + + + + + + + onDelete(comment.id)}> + Delete + + + + + )} +
    +
    + ))} +
    + )} +
    + ); +} diff --git a/frontend/src/Components/Article/Section/BodySection.jsx b/frontend/src/Components/Article/Section/BodySection.jsx index 7f5c2fd..bd4eba8 100644 --- a/frontend/src/Components/Article/Section/BodySection.jsx +++ b/frontend/src/Components/Article/Section/BodySection.jsx @@ -2,12 +2,24 @@ import SectionHeader from "./SectionHeader"; import SectionContent from "./SectionContent"; //Component for one of the sections within the body of an article -//takes in the title and body content -export default function BodySection({ title, body }) { +//takes in the body section blocks +//assumes that if there is a title, it will be the first block +export default function BodySection({ id, bodySectionBlocks }) { + let titleBlock = bodySectionBlocks[0]; + let bodyContentBlocks = [...bodySectionBlocks.slice(1)]; + return ( -
    - - +
    + + {bodyContentBlocks.map((block, index) => { + return ; + })}
    ); } diff --git a/frontend/src/Components/Article/Section/SectionContent.jsx b/frontend/src/Components/Article/Section/SectionContent.jsx index 4d8e0d0..33242a7 100644 --- a/frontend/src/Components/Article/Section/SectionContent.jsx +++ b/frontend/src/Components/Article/Section/SectionContent.jsx @@ -1,3 +1,4 @@ +import BlocksParser from "../../BlocksParser/BlocksParser"; //Component for the body content of an article section's content export default function SectionContent({ content }) { return ( @@ -5,7 +6,9 @@ export default function SectionContent({ content }) {
     
    -

    {content}

    + + +
    ); } diff --git a/frontend/src/Components/Article/Section/SectionHeader.jsx b/frontend/src/Components/Article/Section/SectionHeader.jsx index 9f8d437..062cab1 100644 --- a/frontend/src/Components/Article/Section/SectionHeader.jsx +++ b/frontend/src/Components/Article/Section/SectionHeader.jsx @@ -1,14 +1,17 @@ import ArrowMarker from "../../ArrowMarker/ArrowMarker"; +import BlocksParser from "../../BlocksParser/BlocksParser"; //Component for the title of a article section -export default function SectionHeader({ header }) { +export default function SectionHeader({ headerBlock }) { return (
    {/*
     
    */}
    -

    {header}

    +

    + +

    ); } diff --git a/frontend/src/Components/Article/Section/TableOfContents.jsx b/frontend/src/Components/Article/Section/TableOfContents.jsx index 8c1e5ea..c3bfbef 100644 --- a/frontend/src/Components/Article/Section/TableOfContents.jsx +++ b/frontend/src/Components/Article/Section/TableOfContents.jsx @@ -1,25 +1,29 @@ import { Stack } from "react-bootstrap"; import SectionHeader from "./SectionHeader"; +import BlocksParser from "../../BlocksParser/BlocksParser"; /* -component for table of contents in article page, takes in array of strings representing -table of content headings +component for table of contents in article page, +takes in array of objects containing heading block data text and section id --will display the number corresponding to its position next to it */ -export default function TableOfContents({ content_headings }) { +export default function TableOfContents({ contentSequence }) { return (
    - +
     
    - {content_headings.map((content_heading, index) => ( -

    - {index + 1}. {content_heading} -

    - ))} + {contentSequence.map(({ heading, link }, index) => { + return ( + + {index + 1}. + + ); + })}
    ); diff --git a/frontend/src/Components/Article/Topics/RelatedTopicsList.jsx b/frontend/src/Components/Article/Topics/RelatedTopicsList.jsx index a34761a..c5acb5d 100644 --- a/frontend/src/Components/Article/Topics/RelatedTopicsList.jsx +++ b/frontend/src/Components/Article/Topics/RelatedTopicsList.jsx @@ -15,7 +15,7 @@ import TopicList from "./TopicList"; export default function RelatedTopicsList({ topicLists }) { return ( -

    Related Topics

    +

    Related Topics

    {topicLists.map(({ topicCategory, topicList }) => ( { + //Initialize editorjs if we don't have a reference + if (!ref.current) { + const editor = new EditorJS({ + holder: editorBlockId, + tools: EDITOR_JS_TOOLS, + data: data, + onReady: async () => { + // new Undo({ editor }); + // new DragDrop(editor); + }, + async onChange(api, event) { + const data = await api.saver.save(); + onChange(data); + }, + defaultBlock: "header", + blockToolbar: false, + }); + + ref.current = editor; + } + + //Add a return function to handle cleanup + return () => { + if (ref.current && ref.current.destroy) { + ref.current.destroy(); + } + }; + }, []); + + return
    ; +} diff --git a/frontend/src/Components/ArticleEditor/ArticleEditorHelpers.js b/frontend/src/Components/ArticleEditor/ArticleEditorHelpers.js new file mode 100644 index 0000000..7950c25 --- /dev/null +++ b/frontend/src/Components/ArticleEditor/ArticleEditorHelpers.js @@ -0,0 +1,86 @@ +function couldBeCounted(block) { + return "text" in block.data; +} + +function getBlocksTextLen(blocks) { + return blocks.filter(couldBeCounted).reduce((sum, block) => { + sum += block.data.text.length; + + return sum; + }, 0); +} + +async function enforceCharLimit(content, event, api, charLimit) { + const contentLen = getBlocksTextLen(content.blocks); + + if (contentLen > charLimit) { + const workingBlock = event.detail.target; + const workingBlockIndex = event.detail.index; + const workingBlockId = workingBlock.id; + + // Get the current data of the working block + const workingBlockSaved = content.blocks.find( + (block) => block.id === workingBlockId + ); + + if (workingBlockSaved) { + // Calculate the remaining character limit for the working block + const otherBlocks = content.blocks.filter( + (block) => block.id !== workingBlockId + ); + const otherBlocksLen = getBlocksTextLen(otherBlocks); + const workingBlockLimit = charLimit - otherBlocksLen; + + // Update the working block with the limited text + try { + await api.blocks.update(workingBlockId, { + type: workingBlockSaved.type, + data: { + ...workingBlockSaved.data, + text: workingBlockSaved.data.text.substr(0, workingBlockLimit), + }, + }); + } catch (error) { + console.error("Error updating block:", error); + } + // Adjust index if necessary based on the current block list + api.caret.setToBlock(workingBlockIndex, "end"); + } else { + console.error("Working block not found"); + } + } +} + +async function enforceBlockLimit(content, event, api, blockLimit) { + if (api.blocks.getBlocksCount() > blockLimit) { + //if they make another block, get the text from the new block + //bring it to the previous block, and delete the new block + + const newBlockIndex = api.blocks.getCurrentBlockIndex(); + + const newBlock = content.blocks.filter( + (block) => block.id === event.detail.target.id + )[0]; + + const newBlockText = newBlock ? newBlock.data.text : ""; + const prevBlock = content.blocks[newBlockIndex - 1]; + if (prevBlock) { + api.blocks.update(prevBlock.id, { + type: prevBlock.type, + data: { + ...prevBlock.data, + text: (prevBlock.data.text || "") + (newBlockText || ""), + }, + }); + } + + api.blocks.delete(newBlockIndex); + } +} + +export { + couldBeCounted, + getBlocksTextLen, + enforceBlockLimit, + enforceCharLimit, +}; diff --git a/frontend/src/Components/ArticleEditor/ArticlePreview.jsx/ArticlePreview.jsx b/frontend/src/Components/ArticleEditor/ArticlePreview.jsx/ArticlePreview.jsx new file mode 100644 index 0000000..ab91ca4 --- /dev/null +++ b/frontend/src/Components/ArticleEditor/ArticlePreview.jsx/ArticlePreview.jsx @@ -0,0 +1,108 @@ +import { Container, Row, Col, Stack } from "react-bootstrap"; +import ArticleHeader from "../../Article/ArticleHeader/ArticleHeader"; +import ArticleImage from "../../Article/ArticleImage"; +import BodySection from "../../Article/Section/BodySection"; +import { useAuthContext } from "../../../hooks/useAuthContext"; +import TableOfContents from "../../Article/Section/TableOfContents"; +import "./ArticlePreview.scss"; +export default function ArticlePreview({ articleEditorData, user }) { + //insert dummy data if title and description blocks are empty + let titleBlocks = articleEditorData.header.blocks; + let descriptionBlocks = articleEditorData.description.blocks; + + if (titleBlocks.length == 0 || titleBlocks[0].data.text.length == 0) { + titleBlocks = [ + { type: "header", data: { text: "Article Title", level: 1 } }, + ]; + } + if ( + descriptionBlocks.length == 0 || + descriptionBlocks[0].data.text.length == 0 + ) { + descriptionBlocks = [ + { type: "paragraph", data: { text: "This is a description." } }, + ]; + } + + let contentSequence = []; + + for (const sectionIndex in articleEditorData.articleBody) { + const section = articleEditorData.articleBody[sectionIndex]; + if (section.blocks.length > 0 && section.blocks[0].type === "header") { + contentSequence.push({ + heading: section.blocks[0], + link: `#${section.id}`, + }); + } else { + contentSequence.push({ + heading: { + type: "header", + data: { text: `Section ${Number(sectionIndex) + 1}` }, + }, + link: `#${section.id}`, + }); + } + } + + return ( + + + + { + let today = new Date(); + var dd = String(today.getDate()).padStart(2, "0"); + var mm = today.toLocaleString("default", { month: "long" }); + var yyyy = today.getFullYear(); + + today = mm + " " + dd + ", " + yyyy; + return today; + })()} + disableBookmark + /> + + + + + + + + + + + + + + + {articleEditorData.articleBody.map((bodySection, index) => { + //assumes that if there is a title, it will be the first block + + let currentBodySectionBlocks = [...bodySection.blocks]; + + //if the first block isn't a header, it will insert a dummy header + if ( + bodySection.blocks.length > 0 && + bodySection.blocks[0].type !== "header" + ) { + currentBodySectionBlocks.splice(0, 0, { + type: "header", + data: { text: `Section ${index + 1}`, level: 2 }, + }); + } + return ( + + ); + })} + + + + + ); +} diff --git a/frontend/src/Components/ArticleEditor/ArticlePreview.jsx/ArticlePreview.scss b/frontend/src/Components/ArticleEditor/ArticlePreview.jsx/ArticlePreview.scss new file mode 100644 index 0000000..6a23731 --- /dev/null +++ b/frontend/src/Components/ArticleEditor/ArticlePreview.jsx/ArticlePreview.scss @@ -0,0 +1,18 @@ +.preview-container { + max-width: 1000px; + padding-bottom: 30px; + + .article-image { + max-width: 100%; + width: 1000px; + height: auto; + max-height: 500px; + object-fit: cover; + } + + img { + &:hover { + cursor: pointer; + } + } +} diff --git a/frontend/src/Components/ArticleEditor/BodySectionEditor/BodySectionEditor.jsx b/frontend/src/Components/ArticleEditor/BodySectionEditor/BodySectionEditor.jsx new file mode 100644 index 0000000..513c1a5 --- /dev/null +++ b/frontend/src/Components/ArticleEditor/BodySectionEditor/BodySectionEditor.jsx @@ -0,0 +1,115 @@ +import EditorJS from "@editorjs/editorjs"; +import Paragraph from "@editorjs/paragraph"; +import { useRef, useState, useEffect } from "react"; +import Strikethrough from "@sotaproject/strikethrough"; +import Undo from "editorjs-undo"; +import { enforceBlockLimit, enforceCharLimit } from "../ArticleEditorHelpers"; +import List from "@editorjs/list"; +import SimpleImage from "@editorjs/simple-image"; +import Header from "@editorjs/header"; +export const EDITOR_JS_TOOLS = { + header: { + class: Header, + config: { + levels: [2], + defaultLevel: 2, + enableLineBreaks: false, + }, + }, + paragraph: { + class: Paragraph, + inlineToolBar: true, + config: { + preserveBlank: true, + }, + }, + list: { + class: List, + inlineToolbar: true, + }, + strikethrough: Strikethrough, + image: { + class: SimpleImage, + inlineToolBar: true, + }, +}; + +const BODY_SECTION_MAX_BLOCKS = 1000; +export default function BodySectionEditor({ + data, + onChange, + editorBlockId, + charLimit, + hasLoadedInitialData, +}) { + const editorRef = useRef(); + const hasRenderedInitialData = useRef(false); + const [isEditorReady, setIsEditorReady] = useState(false); + + useEffect(() => { + //Initialize editorjs if we don't have a reference + if (!editorRef.current) { + const editor = new EditorJS({ + holder: editorBlockId, + tools: EDITOR_JS_TOOLS, + data: data, + onReady: async () => { + new Undo({ editor }); + setIsEditorReady(true); + }, + async onChange(api, event) { + const content = await api.saver.save(); + const onChangeEvent = Array.isArray(event) ? event : [event]; + + for (let currEvent of onChangeEvent) { + await enforceBlockLimit( + content, + currEvent, + api, + BODY_SECTION_MAX_BLOCKS + ); + await enforceCharLimit(content, currEvent, api, charLimit); + } + onChange({ + ...content, + blocks: content.blocks.slice(0, BODY_SECTION_MAX_BLOCKS), + }); + }, + }); + + editorRef.current = editor; + } + + //Add a return function to handle cleanup + return () => { + if (editorRef.current && editorRef.current.destroy) { + editorRef.current.destroy(); + editorRef.current = null; + hasRenderedInitialData.current = false; + } + }; + }, []); + useEffect(() => { + if ( + isEditorReady && + hasLoadedInitialData && + data && + !hasRenderedInitialData.current + ) { + editorRef.current + .clear() + .then(() => editorRef.current.render(data)) + .then(() => { + hasRenderedInitialData.current = true; + }) + .catch((err) => console.error("EditorJS render error:", err)); + } + }, [isEditorReady, hasLoadedInitialData, data]); + + useEffect(() => { + if (hasLoadedInitialData && data) { + // console.log("BodySectionEditor initial data:", data); + } + }, [hasLoadedInitialData, data]); + return
    ; +} diff --git a/frontend/src/Components/ArticleEditor/DescEditor/DescEditor.jsx b/frontend/src/Components/ArticleEditor/DescEditor/DescEditor.jsx new file mode 100644 index 0000000..0c444fb --- /dev/null +++ b/frontend/src/Components/ArticleEditor/DescEditor/DescEditor.jsx @@ -0,0 +1,94 @@ +import EditorJS from "@editorjs/editorjs"; +import Paragraph from "@editorjs/paragraph"; +import { useRef, useState, useEffect } from "react"; +import Strikethrough from "@sotaproject/strikethrough"; +import Undo from "editorjs-undo"; +import { enforceBlockLimit, enforceCharLimit } from "../ArticleEditorHelpers"; +import { Image, Form, Button } from "react-bootstrap"; +import "./DescEditor.scss"; +export const EDITOR_JS_TOOLS = { + paragraph: { + class: Paragraph, + inlineToolBar: true, + config: { + preserveBlank: true, + }, + }, + strikethrough: Strikethrough, +}; + +const DESC_MAX_BLOCKS = 10; +export default function DescEditor({ + data, + onChange, + editorBlockId, + charLimit, + hasLoadedInitialData, +}) { + const editorRef = useRef(); + const hasRenderedInitialData = useRef(false); + const [isEditorReady, setIsEditorReady] = useState(false); + useEffect(() => { + //Initialize editorjs if we don't have a reference + if (!editorRef.current) { + const editor = new EditorJS({ + holder: editorBlockId, + tools: EDITOR_JS_TOOLS, + placeholder: "Write text...", + data: data, + onReady: async () => { + new Undo({ editor }); + setIsEditorReady(true); + }, + async onChange(api, event) { + const content = await api.saver.save(); + const onChangeEvent = Array.isArray(event) ? event : [event]; + + for (let currEvent of onChangeEvent) { + await enforceBlockLimit(content, currEvent, api, DESC_MAX_BLOCKS); + await enforceCharLimit(content, currEvent, api, charLimit); + } + + onChange({ + ...content, + blocks: content.blocks.slice(0, DESC_MAX_BLOCKS), + }); + }, + }); + + editorRef.current = editor; + } + + //Add a return function to handle cleanup + return () => { + if (editorRef.current && editorRef.current.destroy) { + editorRef.current.destroy(); + editorRef.current = null; + hasRenderedInitialData.current = false; + } + }; + }, []); + + useEffect(() => { + if ( + isEditorReady && + hasLoadedInitialData && + data && + !hasRenderedInitialData.current + ) { + editorRef.current + .clear() + .then(() => editorRef.current.render(data)) + .then(() => { + hasRenderedInitialData.current = true; + }) + .catch((err) => console.error("EditorJS render error:", err)); + } + }, [isEditorReady, hasLoadedInitialData, data]); + + return ( + <> +
    + + ); +} diff --git a/frontend/src/Components/ArticleEditor/DescEditor/DescEditor.scss b/frontend/src/Components/ArticleEditor/DescEditor/DescEditor.scss new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/Components/ArticleEditor/HeaderEditor/HeaderEditor.jsx b/frontend/src/Components/ArticleEditor/HeaderEditor/HeaderEditor.jsx new file mode 100644 index 0000000..146a7c7 --- /dev/null +++ b/frontend/src/Components/ArticleEditor/HeaderEditor/HeaderEditor.jsx @@ -0,0 +1,104 @@ +import EditorJS from "@editorjs/editorjs"; +import Header from "@editorjs/header"; +import Paragraph from "@editorjs/paragraph"; +import { useEffect } from "react"; +import { useRef, useState } from "react"; +import Undo from "editorjs-undo"; +import "./HeaderEditor.scss"; +import { enforceBlockLimit, enforceCharLimit } from "../ArticleEditorHelpers"; + +export const EDITOR_JS_TOOLS = { + header: { + class: Header, + config: { + levels: [1, 2], + defaultLevel: 1, + enableLineBreaks: false, + }, + }, + paragraph: { + class: Paragraph, + inlineToolBar: true, + config: { + preserveBlank: true, + }, + }, +}; + +//keep to one block +const HEADER_MAX_BLOCKS = 1; +export default function HeaderEditor({ + data, + onChange, + editorBlockId, + charLimit, + hasLoadedInitialData, +}) { + const editorRef = useRef(); + const hasRenderedInitialData = useRef(false); + const [isEditorReady, setIsEditorReady] = useState(false); + + useEffect(() => { + //Initialize editorjs if we don't have a reference + if (!editorRef.current) { + const editor = new EditorJS({ + holder: editorBlockId, + tools: EDITOR_JS_TOOLS, + data: data, + + onReady: async (api) => { + new Undo({ editor }); + setIsEditorReady(true); + }, + async onChange(api, event) { + const content = await api.saver.save(); + const onChangeEvent = Array.isArray(event) ? event : [event]; + + for (let currEvent of onChangeEvent) { + await enforceBlockLimit(content, currEvent, api, HEADER_MAX_BLOCKS); + await enforceCharLimit(content, currEvent, api, charLimit); + } + + onChange({ + ...content, + blocks: content.blocks.slice(0, HEADER_MAX_BLOCKS), + }); + }, + defaultBlock: "header", + }); + + editorRef.current = editor; + } + + //Add a return function to handle cleanup + return () => { + if (editorRef.current && editorRef.current.destroy) { + editorRef.current.destroy(); + editorRef.current = null; + hasRenderedInitialData.current = false; + } + }; + }, []); + + useEffect(() => { + if ( + isEditorReady && + hasLoadedInitialData && + data && + !hasRenderedInitialData.current + ) { + editorRef.current + .clear() + .then(() => editorRef.current.render(data)) + .then(() => { + hasRenderedInitialData.current = true; + }) + .catch((err) => console.error("EditorJS render error:", err)); + } + }, [isEditorReady, hasLoadedInitialData, data]); + return ( + <> +
    + + ); +} diff --git a/frontend/src/Components/ArticleEditor/HeaderEditor/HeaderEditor.scss b/frontend/src/Components/ArticleEditor/HeaderEditor/HeaderEditor.scss new file mode 100644 index 0000000..a81a687 --- /dev/null +++ b/frontend/src/Components/ArticleEditor/HeaderEditor/HeaderEditor.scss @@ -0,0 +1,21 @@ +#header-editor { + .ce-toolbar { + display: none !important; + } +} + +.nav-tabs { + height: 100px; +} +.nav-tabs .nav-link { + color: white; + background-color: #79A2D2; + opacity: 50%; +} + +.nav-tabs .nav-item .nav-link.active { + color: #fff !important; + background-color: #79A2D2 !important; + border-color: #007bff !important; + opacity: 100%; +} \ No newline at end of file diff --git a/frontend/src/Components/ArticleResults/ArticleResult/ArticleResult.jsx b/frontend/src/Components/ArticleResults/ArticleResult/ArticleResult.jsx new file mode 100644 index 0000000..9093f5f --- /dev/null +++ b/frontend/src/Components/ArticleResults/ArticleResult/ArticleResult.jsx @@ -0,0 +1,58 @@ +import { Image } from "react-bootstrap"; +import "./ArticleResult.scss"; +import { + Bookmark, + BookmarkFill, + HeartFill, + ChatSquareTextFill, +} from "react-bootstrap-icons"; +import { useNavigate } from "react-router-dom"; +import BlocksParser from "../../BlocksParser/BlocksParser"; +export default function ArticleResult({ article, bookmarkToggler }) { + const navigate = useNavigate(); + + const articleNavigate = () => { + navigate(`/article_view/${article.id}`); + }; + return ( +
    +
    + +
    +
    + {article.isBookmarked ? ( + + ) : ( + + )} +
    +
    +

    + +

    +
    + By {article.author} + + Published {article.published_at} + +
    +
    +
    + + {article.like_count} +
    +
    + + {article.comment_count} +
    +
    +
    +
    + ); +} diff --git a/frontend/src/Components/ArticleResults/ArticleResult/ArticleResult.scss b/frontend/src/Components/ArticleResults/ArticleResult/ArticleResult.scss new file mode 100644 index 0000000..48c0cf7 --- /dev/null +++ b/frontend/src/Components/ArticleResults/ArticleResult/ArticleResult.scss @@ -0,0 +1,124 @@ +@import "bootstrap/scss/bootstrap-grid.scss"; +@import "bootstrap/scss/grid"; +@import "bootstrap/scss/variables"; +@import "bootstrap/scss/mixins"; +@import "bootstrap/scss/functions"; +@import "../../../scss/variables"; +/*overall article result container*/ +.article-result { + display: flex; + gap: 10px; + height: 100px; + @include media-breakpoint-up(sm) { + height: 150px; + } +} + +/*image*/ +.article-result-image-container { + width: fit-content; + display: flex; + height: inherit; + justify-content: center; +} + +.article-result-image { + object-fit: contain; + object-position: center top; + height: 100%; + width: 75px; + @include media-breakpoint-up(sm) { + width: 150px; + } + @include media-breakpoint-up(md) { + width: 200px; + } +} +.article-result-bookmark { + width: fit-content; + + &:hover { + cursor: pointer; + } + &:active { + cursor: auto; + } +} + +/*Text*/ +.article-result-text, +.article-result-info { + display: flex; + flex-direction: column; +} +.article-result-text { + gap: 5px; +} +.article-result-info { + gap: 2.5px; +} + +.article-result-title { + font-weight: 600; + text-transform: uppercase; + margin-bottom: 0 !important; + font-size: calc(0.8rem + 0.5vw); + @include media-breakpoint-up(sm) { + font-size: calc(1.2rem + 0.5vw); + } + /*style for clicking on article title*/ + &:hover { + cursor: pointer; + text-decoration: underline; + } + &:active { + cursor: auto; + position: relative; + top: 1px; + opacity: 0.8; + } +} +.article-result-author, +.article-result-date { + font-weight: 300; + font-style: italic; + padding: 0 !important; + font-size: calc(0.8rem + 0.1vw); + @include media-breakpoint-up(sm) { + font-size: calc(0.9rem + 0.1vw); + } +} + +//COLOR MODES +@include color-mode(light) { + .article-result-text, + .article-result-author, + .article-result-date { + color: $primary-text-light; + } + .article-result-bookmark { + color: $bookmark-bg-light; + } + .article-result-likes { + color: $like-bg-light; + } + .article-result-comment { + color: $comment-bg-light; + } +} +@include color-mode(dark) { + .article-result-text, + .article-result-author, + .article-result-date { + color: $primary-text-dark; + } + .article-result-bookmark { + color: $bookmark-bg-dark; + } + .article-result-likes { + color: $like-bg-dark; + } + .article-result-comment { + color: $comment-bg-dark; + } +} diff --git a/frontend/src/Components/ArticleResults/ArticleResultsList.jsx b/frontend/src/Components/ArticleResults/ArticleResultsList.jsx new file mode 100644 index 0000000..b96d6c9 --- /dev/null +++ b/frontend/src/Components/ArticleResults/ArticleResultsList.jsx @@ -0,0 +1,22 @@ +import ArticleResult from "./ArticleResult/ArticleResult"; + +export default function ArticleResultsList({ + articles, + bookmarkTogglerCreator, +}) { + return ( +
    + {articles && articles.length > 0 ? ( + articles.map((article) => ( + + )) + ) : ( +

    Your query did not match any results

    + )} +
    + ); +} diff --git a/frontend/src/Components/ArticleResults/SideSections/ArticleResultSideSection.scss b/frontend/src/Components/ArticleResults/SideSections/ArticleResultSideSection.scss new file mode 100644 index 0000000..6b94506 --- /dev/null +++ b/frontend/src/Components/ArticleResults/SideSections/ArticleResultSideSection.scss @@ -0,0 +1,32 @@ +@import "bootstrap/scss/bootstrap-grid.scss"; +@import "bootstrap/scss/grid"; +@import "bootstrap/scss/variables"; +@import "bootstrap/scss/mixins"; +@import "bootstrap/scss/functions"; +@import "../../../scss/variables"; + +.article-results-side-section { +} +.article-results-side-section-title { + margin-bottom: 1rem; + font-size: calc(0.8rem + 0.5vw); + font-weight: 600; + @include media-breakpoint-up(sm) { + font-size: calc(1.2rem + 0.5vw); + } +} + +.article-results-section-content { + padding-left: 10px; +} + +@include color-mode(light) { + .article-results-side-section-title { + color: $primary-text-light; + } +} +@include color-mode(dark) { + .article-results-side-section-title { + color: $primary-text-dark; + } +} diff --git a/frontend/src/Components/ArticleResults/SideSections/RelatedTopicTags/RelatedTopicTags.jsx b/frontend/src/Components/ArticleResults/SideSections/RelatedTopicTags/RelatedTopicTags.jsx new file mode 100644 index 0000000..492cc30 --- /dev/null +++ b/frontend/src/Components/ArticleResults/SideSections/RelatedTopicTags/RelatedTopicTags.jsx @@ -0,0 +1,31 @@ +import "./RelatedTopicTags.scss"; +import "../ArticleResultSideSection.scss"; +//tags : array of tag objects +export default function RelatedTags({ tags }) { + return ( + + ); +} diff --git a/frontend/src/Components/ArticleResults/SideSections/RelatedTopicTags/RelatedTopicTags.scss b/frontend/src/Components/ArticleResults/SideSections/RelatedTopicTags/RelatedTopicTags.scss new file mode 100644 index 0000000..3a63667 --- /dev/null +++ b/frontend/src/Components/ArticleResults/SideSections/RelatedTopicTags/RelatedTopicTags.scss @@ -0,0 +1,36 @@ +@import "bootstrap/scss/bootstrap-grid.scss"; +@import "bootstrap/scss/grid"; +@import "bootstrap/scss/variables"; +@import "bootstrap/scss/mixins"; +@import "bootstrap/scss/functions"; +@import "../../../../scss/variables"; +.related-topic-tag { + background: $related-topic-tag-bg; + color: white; + border-radius: 20px; + padding: 5px 8px; + white-space: nowrap; + font-weight: 600; + font-size: calc(0.5rem + 0.1vw); + @include media-breakpoint-up(sm) { + font-size: calc(0.7rem + 0.1vw); + } + + &:hover { + filter: brightness(1.2); + cursor: pointer; + } + + &:active { + filter: brightness(0.8); + position: relative; + top: 1px; + cursor: auto; + } +} + +.related-topic-tags-container { + display: flex; + flex-flow: wrap; + gap: 10px 8px; +} diff --git a/frontend/src/Components/BlocksParser/BlocksParser.jsx b/frontend/src/Components/BlocksParser/BlocksParser.jsx new file mode 100644 index 0000000..5c79287 --- /dev/null +++ b/frontend/src/Components/BlocksParser/BlocksParser.jsx @@ -0,0 +1,67 @@ +import parse from "html-react-parser"; +import DOMPurify from "dompurify"; +import { Figure } from "react-bootstrap"; +import { useState } from "react"; +import ArticleImagePreview from "../Article/ArticleImagePreview/ArticleImagePreview"; +//returns parsed article +//separating these into switch cases in case we need to have different logic for each type of block +export default function BlocksParser({ blocks }) { + const [show, setShow] = useState(false); + const [previewImage, setPreviewImage] = useState(null); + const [imageCaption, setImageCaption] = useState(null); + + return ( + <> + + + {blocks.map((block, index) => { + let processedBlock = null; + let processedHtmlString = null; + + switch (block.type) { + case "paragraph": + processedHtmlString = DOMPurify.sanitize(block.data.text); + processedBlock = parse(processedHtmlString); + break; + case "header": + processedHtmlString = DOMPurify.sanitize(block.data.text); + processedBlock = parse(processedHtmlString); + break; + case "image": + processedBlock = ( +
    + { + setPreviewImage(block.data.url); + setImageCaption(block.data.caption); + setShow(true); + }} + /> + {block.data.caption} +
    + ); + break; + case "list": + let listItemElements = block.data.items.map((item, index) => { + let itemProcessedHTMLString = DOMPurify.sanitize(item); + return ( +
  • {parse(itemProcessedHTMLString)}
  • + ); + }); + processedBlock =
      {listItemElements}
    ; + break; + default: + break; + } + + return processedBlock; + })} + + ); +} diff --git a/frontend/src/Components/Footer/Footer.jsx b/frontend/src/Components/Footer/Footer.jsx index 9beb388..9abe2ba 100644 --- a/frontend/src/Components/Footer/Footer.jsx +++ b/frontend/src/Components/Footer/Footer.jsx @@ -5,45 +5,38 @@ import logo from "../../assets/logo.png"; export default function Footer() { return (
    + +
    Github - - - - -
    +
    +
    +
    +
    - + + Home +
    -
    - + + -
    - - CS Catalog @ 2024 All Rights Reserved - +
    + + + CS Catalog @ 2024 All Rights Reserved + +
    ); } diff --git a/frontend/src/Components/Footer/Footer.scss b/frontend/src/Components/Footer/Footer.scss index 465e8d9..cbe1c2a 100644 --- a/frontend/src/Components/Footer/Footer.scss +++ b/frontend/src/Components/Footer/Footer.scss @@ -1,5 +1,9 @@ @import "../../scss/variables.scss"; +@import "/node_modules/bootstrap/scss/bootstrap-grid.scss"; +@import "/node_modules/bootstrap/scss/grid"; +@import "/node_modules//bootstrap/scss/mixins"; + //colors .footer { background: $header-footer-bg; @@ -11,10 +15,19 @@ /*First half of footer*/ .footer-logo { - background: $primary; - width: 50%; - max-width: 200px; - min-width: 100px; + @include media-breakpoint-down(md) { + height: 4.5rem; + } + + @include media-breakpoint-up(md) { + height: 6.5rem; + } +} + +.cc-logo-footer { + @include media-breakpoint-up(lg) { + padding-right: 5rem !important; + } } /*Social Links*/ @@ -23,7 +36,6 @@ .footer-social-container > a { width: fit-content; - display: flex; align-items: center; gap: 10px; } @@ -33,6 +45,7 @@ .footer-social-name { font-size: 1rem; font-weight: 200; + margin-left: 10px; } .footer-social-icon, .footer-social-name { @@ -48,21 +61,31 @@ text-align: center; font-size: 1.8rem; font-weight: 300; - > span { + margin-left: 5rem; + > span:first-of-type { font-family: "Fredericka The Great"; - font-size: 4rem; + font-size: 7rem; font-weight: normal; + border-bottom: 1px solid white; } } +.vertical-divider { + border-right: 1px solid white; + width: 1px; +} /*more info links*/ .footer-more-info-links-container { + padding-left: 50px; + display: flex; + gap: 10px; } .footer-more-info-link-container { white-space: nowrap; overflow: hidden; + width: 147px; } .footer-socials-list, @@ -76,7 +99,7 @@ .social-link, .more-info-link { text-decoration: none; - + margin-top: 40px; &:hover { font-weight: 700 !important; cursor: pointer; @@ -92,4 +115,29 @@ font-style: italic; font-weight: 200; font-size: 0.9rem; + padding-top: 1rem; +} + +.footer-container { + width: 50rem !important; + @include media-breakpoint-down(md) { + width: 20rem !important; + } +} + +.links-container { + display: flex; + justify-content: space-between; + padding: 0 !important; + + @include media-breakpoint-down(lg) { + padding: 20px 0 !important; + justify-content: space-evenly; + } +} + +.social-icons-container { + display: flex; + flex-direction: column; + gap: 30px; } diff --git a/frontend/src/Components/Header/Header.jsx b/frontend/src/Components/Header/Header.jsx index 427452f..7c4825f 100644 --- a/frontend/src/Components/Header/Header.jsx +++ b/frontend/src/Components/Header/Header.jsx @@ -1,5 +1,3 @@ -import logo from "../../assets/logo.png"; -import avatar from "../../assets/avatar.jpg"; import { Container, Nav, @@ -10,26 +8,58 @@ import { OverlayTrigger, Popover, Button, - DropdownButton, - ButtonGroup, - Dropdown, } from "react-bootstrap"; import SearchBar from "../SearchBar"; + import { SunFill, MoonFill } from "react-bootstrap-icons"; +import { useState, useEffect } from "react"; +import { useAuthContext } from "../../hooks/useAuthContext"; +import { useLogout } from "../../hooks/useLogout"; import "./Header.scss"; -import { useState } from "react"; +import toast from "react-hot-toast"; +import { useNavigate } from "react-router-dom"; + +const DEFAULT_AVATAR = "/default_avatar.jpg"; export default function Header() { - const [isDark, setIsDark] = useState(false); + const { user } = useAuthContext(); + const navigate = useNavigate(); + const { logout } = useLogout(); + const handleLogout = () => { + logout(); + }; + const [theme, setTheme] = useState(() => { + const stored = localStorage.getItem("theme"); + return ( + stored || + (window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light") + ); + }); + + const checkLoggedIn = (e) => { + if (!user) { + e.preventDefault(); + logout(); + navigate("/signin"); + toast.error("Please login or create an account."); + e.stopPropagation(); + } + }; + useEffect(() => { + document.documentElement.setAttribute("data-bs-theme", theme); + localStorage.setItem("theme", theme); + }, [theme]); return ( <> - +
    @@ -63,11 +93,27 @@ export default function Header() {
    - -
    + +
    + +
    + {!user && Log in to access bookmarked articles.} {articles.map((article) => ( diff --git a/frontend/src/Components/Settings/Settings.scss b/frontend/src/Components/Settings/Settings.scss index d98b105..05ec5bd 100644 --- a/frontend/src/Components/Settings/Settings.scss +++ b/frontend/src/Components/Settings/Settings.scss @@ -42,7 +42,7 @@ //settings field header .settings-section-field-header { - font-weight: 500; + font-weight: 300; } //settings edit button @@ -134,6 +134,23 @@ font-size: calc(0.9rem + 0.1vw); } +.profile-avatar { + width: 10rem; + height: 10rem; +} + +.avatar-edit-container { + top: 6.2rem; + left: 8.2rem; +} + +.avatar-edit { + border-radius: 50% !important; + width: 2.5rem; + height: 2.5rem; + cursor: pointer; +} + //COLOR MODES @include color-mode(dark) { .settings-header, diff --git a/frontend/src/Components/Signin_Signup/Logo/Logo.jsx b/frontend/src/Components/Signin_Signup/Logo/Logo.jsx index ae6a2f0..00a09b7 100644 --- a/frontend/src/Components/Signin_Signup/Logo/Logo.jsx +++ b/frontend/src/Components/Signin_Signup/Logo/Logo.jsx @@ -1,23 +1,16 @@ import { Stack, Image } from "react-bootstrap"; -import logo from "../../../assets/logo.png"; +import "./Logo.scss"; export default function Logo() { return ( - -

    + +

    Learn - Explore - Innovate

    -

    +

    CS Knowledge at Your Fingertips -

    +

    ); } diff --git a/frontend/src/Components/Signin_Signup/Logo/Logo.scss b/frontend/src/Components/Signin_Signup/Logo/Logo.scss new file mode 100644 index 0000000..173228b --- /dev/null +++ b/frontend/src/Components/Signin_Signup/Logo/Logo.scss @@ -0,0 +1,26 @@ +@import "bootstrap/scss/bootstrap-grid.scss"; +@import "bootstrap/scss/grid"; +@import "bootstrap/scss/variables"; +@import "bootstrap/scss/mixins"; +@import "../../../scss/variables"; + +.cc-logo { + margin-bottom: 10px; + width: 120px; + + @include media-breakpoint-up(xxl) { + width: 200px; + } +} + +.middle-slogan { + @include media-breakpoint-up(xxl) { + font-size: 2.5em; + } +} + +.bottom-slogan { + @include media-breakpoint-up(xxl) { + font-size: 1.5em; + } +} diff --git a/frontend/src/Components/Signin_Signup/Signin/SigninCard.jsx b/frontend/src/Components/Signin_Signup/Signin/SigninCard.jsx index bcad4ed..adfdc9f 100644 --- a/frontend/src/Components/Signin_Signup/Signin/SigninCard.jsx +++ b/frontend/src/Components/Signin_Signup/Signin/SigninCard.jsx @@ -1,55 +1,80 @@ import { useState } from "react"; import { Form, Button, InputGroup, Container, Row, Col } from "react-bootstrap"; import * as auth from "../../auth/auth"; - +import { useLogin } from "../../../hooks/useLogin"; import { EyeFill, EyeSlashFill } from "react-bootstrap-icons"; import "./Signin.scss"; import "../SignForm.scss"; +import { useLoadingSpinner } from "../../../context/SpinnerContext"; + +const GoogleIcon = () => ( + + {/* SVG content */} + +); + export default function SigninCard() { + const { showSpinner, hideSpinner } = useLoadingSpinner(); + const [formVal, setFormVal] = useState({ username: "", password: "", }); const [showPassword, setShowPassword] = useState(false); - //whether form has run through validation yet const [isValidated, setValidated] = useState(false); - - // error messages const [errorMessages, setErrorMessages] = useState({}); + const { login, isLoading, error } = useLogin(); - // handle input entered const handleInput = (e) => { const { name, value } = e.target; - - setFormVal({ - ...formVal, + setFormVal((prevState) => ({ + ...prevState, [name]: value, - }); + })); }; const handlePasswordToggle = () => { setShowPassword(!showPassword); }; - // handle submit - const handleSubmit = (e) => { + const handleSubmit = async (e) => { e.preventDefault(); const errMessagesList = {}; const checkEmpty = auth.validationFunctions.checkEmpty; - // check empty for now (will add more authentication from backend soon) + // Validation for (const fieldName in formVal) { - let validateResult = checkEmpty(fieldName, formVal[fieldName]); - + const validateResult = checkEmpty(fieldName, formVal[fieldName]); if (typeof validateResult === "string") { errMessagesList[fieldName] = validateResult; } } - - setValidated(true); setErrorMessages(errMessagesList); + + if (Object.keys(errMessagesList).length === 0) { + // If no errors, proceed with login + try { + showSpinner(); + await login({ username: formVal.username, password: formVal.password }); + // Redirect or perform other actions on successful login + } catch (err) { + setErrorMessages({ form: "Invalid credentials" }); + } finally { + hideSpinner(); + } + } else { + setValidated(true); + } }; return ( @@ -59,21 +84,21 @@ export default function SigninCard() { onSubmit={handleSubmit} className="sign-form" > -

    Login

    +

    + Login +

    - {errorMessages.hasOwnProperty("username") - ? errorMessages.username - : ""} + {errorMessages.username || ""} @@ -83,7 +108,7 @@ export default function SigninCard() { value={formVal.password} type={showPassword ? "text" : "password"} placeholder="Password" - isInvalid={errorMessages.hasOwnProperty("password")} + isInvalid={!!errorMessages.password} onChange={handleInput} required className="sign-text-input" @@ -92,21 +117,23 @@ export default function SigninCard() { title={showPassword ? "hide password" : "show password"} className="my-auto bg-white border-white sign-password-visbility-button" onClick={handlePasswordToggle} + type="button" > {showPassword ? : } - {errorMessages.hasOwnProperty("password") - ? errorMessages.password - : ""} + {errorMessages.password || ""} - + {errorMessages.form && ( +

    {errorMessages.form}

    + )} + {error &&

    {error}

    }
    - @@ -144,55 +171,3 @@ export default function SigninCard() { ); } - -const GoogleIcon = () => ( - - - - - - - - - - - -); diff --git a/frontend/src/Components/Signin_Signup/Signup/SignupCard.jsx b/frontend/src/Components/Signin_Signup/Signup/SignupCard.jsx index 462f6da..4ccae23 100644 --- a/frontend/src/Components/Signin_Signup/Signup/SignupCard.jsx +++ b/frontend/src/Components/Signin_Signup/Signup/SignupCard.jsx @@ -1,13 +1,16 @@ import { useEffect, useState } from "react"; -import { Card, Form, Button, InputGroup } from "react-bootstrap"; -import { Link, useNavigate } from "react-router-dom"; +import { Form, Button, InputGroup } from "react-bootstrap"; +import { Link } from "react-router-dom"; import * as auth from "../../auth/auth"; import "./Signup.scss"; import "../SignForm.scss"; - +import { useSignup } from "../../../hooks/useSignup"; import { EyeFill, EyeSlashFill } from "react-bootstrap-icons"; +import { useLoadingSpinner } from "../../../context/SpinnerContext"; export default function SignupCard() { + const { showSpinner, hideSpinner } = useLoadingSpinner(); + const [formVal, setFormVal] = useState({ username: "", fname: "", @@ -18,15 +21,17 @@ export default function SignupCard() { }); const [showPassword, setShowPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false); - const navigate = useNavigate(); - //whether form has run through validation yet + // Whether form has run through validation yet const [isValidated, setValidated] = useState(false); - // error messages + // Error messages const [errorMessages, setErrorMessages] = useState({}); - // handle input entered + // Import the useSignup hook + const { signup, isLoading, error } = useSignup(); + + // Handle input entered const handleInput = (e) => { const { name, value } = e.target; @@ -44,25 +49,18 @@ export default function SignupCard() { setShowConfirmPassword(!showConfirmPassword); }; + //does not work because state is not manipulated instantly, so this can potentially be checking before errors are being pushed here const isValidationPassed = () => { - return Object.keys(errorMessages).length === 0 ? true : false; + return Object.keys(errorMessages).length === 0; }; - useEffect(() => { - // might add API endpoints to handle backend authentication here - if (isValidated && isValidationPassed()) { - navigate("/signin"); - } - }, [isValidationPassed]); - - // handle submit - const handleSubmit = (e) => { + // Handle submit + const handleSubmit = async (e) => { e.preventDefault(); - const newErrMessages = {}; const formValidation = auth.formValidation; - for (const fieldName in formValidation) { + for (const fieldName in formVal) { const validationFuncs = formValidation[fieldName]; validationFuncs.forEach((validationFunc) => { @@ -80,6 +78,18 @@ export default function SignupCard() { setValidated(true); setErrorMessages(newErrMessages); + // Call the signup function if no validation errors + if (Object.keys(newErrMessages).length === 0) { + showSpinner(); + await signup({ + first_name: formVal.fname, + last_name: formVal.lname, + username: formVal.username, + password: formVal.password, + email: formVal.email, + }); + hideSpinner(); + } }; return ( @@ -89,7 +99,9 @@ export default function SignupCard() { onSubmit={handleSubmit} className="sign-form" > -

    Sign Up

    +

    + Sign Up +

    + {error &&
    {error}
    }
    -
    diff --git a/frontend/src/Components/Signin_Signup/WelcomeSection/WelcomeSection.jsx b/frontend/src/Components/Signin_Signup/WelcomeSection/WelcomeSection.jsx index cb1b8cd..463dab1 100644 --- a/frontend/src/Components/Signin_Signup/WelcomeSection/WelcomeSection.jsx +++ b/frontend/src/Components/Signin_Signup/WelcomeSection/WelcomeSection.jsx @@ -1,23 +1,24 @@ -import { Button, Stack } from "react-bootstrap"; +import { Button, Stack, Image } from "react-bootstrap"; import { Link, useNavigate } from "react-router-dom"; import "./WelcomeSection.scss"; +import BrandName from "../../Home/AppBrand/BrandName"; + export default function WelcomeSection() { const navigate = useNavigate(); return (
    -

    - Welcome to - - CSCatalog - -

    +

    Welcome to

    +
    + +
    + - - +
    +

    Title

    +
    + +
    +
    +
    +

    Description

    +
    + {" "} +
    +
    +
    +

    Article Image

    + {articleEditorData.image && ( +
    + Uploaded preview setShow(true)} + /> +
    + )} +
    + + Upload Image + + +
    +
    +
    +

    Article Body

    +
    + {articleEditorData.articleBody.map( + (articleBodySectionData, index) => { + return ( +
    e.preventDefault()} + onDragStart={(e) => handleDragStart(e, index)} + onDrop={(e) => handleDrop(e, index)} + > +
    + +
    +
    + + + + removeBodySection(index)} + /> +
    +
    +
    + ); + } + )} + +
    +
    + + ) : ( + <> + + + )} +
    + + ); +} diff --git a/frontend/src/pages/ArticleEditorPage/ArticleEditorPage.scss b/frontend/src/pages/ArticleEditorPage/ArticleEditorPage.scss new file mode 100644 index 0000000..711c7cb --- /dev/null +++ b/frontend/src/pages/ArticleEditorPage/ArticleEditorPage.scss @@ -0,0 +1,209 @@ +@import "../../../node_modules/bootstrap/scss/bootstrap-grid.scss"; +@import "../../../node_modules/bootstrap/scss/grid"; +@import "../../../node_modules/bootstrap/scss/variables"; +@import "../../../node_modules//bootstrap/scss/mixins"; +@import "../../scss/variables"; + +//create an image preview- use lightbox? or maybe modify modals + +@include color-mode(dark) { + body { + background-color: $primary-bg-dark; + } +} + +.tab-contents { + position: sticky; + z-index: 1000; + top: var(--header-height, 164px); + height: 70px; +} + +.floating-button { + position: sticky; + top: calc(100vh - 50px); + left: 200vh; + margin-bottom: 10px; + margin-right: 10px; + z-index: 1000; + color: aliceblue !important; + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15); + + &:hover { + transform: scale(1.05); + } +} + +.container { + margin-top: 10px; + padding: 10px 50px; + + .header { + font-weight: bold; + font-size: 1.5rem; + max-width: 1000px; + margin: 0 auto; + padding: 10px 0; + } + + .text-container { + max-width: 1000px; + padding: 20px; + border-radius: 16px; + display: flex; + flex-direction: column; + margin: 0 auto; + word-wrap: break-word; + + .article-body-section { + display: flex; + align-items: flex-start; + width: 100%; + gap: 16px; + + &:not(:last-child) { + border-bottom: 1px solid; + padding: 10px 0; + } + + .body-section-editor { + flex-grow: 1; + + .ce-block__content, + .ce-toolbar__content { + width: 100%; + max-width: 80ch; + } + } + + .icons { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; + + #trash-icon { + cursor: pointer; + transition: transform 0.2s ease; + + &:hover { + transform: scale(1.2); + } + } + + #drag-icon { + cursor: move; + transition: transform 0.2s ease; + + &:hover { + transform: scale(1.2); + } + } + } + } + + #plus-button { + align-self: flex-end; + cursor: pointer; + padding-top: 10px; + box-sizing: content-box; + transition: transform 0.2s ease; + + &:hover { + transform: scale(1.2); + } + } + } +} + +#body-container { + padding-bottom: 60px; +} + +#header-editor { + .codex-editor__redactor { + padding-bottom: 20px !important; + } + .ce-block__content { + margin-left: 5px; + margin-right: 5px; + max-width: none; + } +} + +#desc-editor { + .codex-editor__redactor { + padding-bottom: 0px !important; + } + + .ce-block__content, + .ce-toolbar__content { + width: 100%; + max-width: 85ch; + } +} + +.image-container { + max-width: 1000px; + margin: 0 auto; + + img { + width: 1000px; + height: auto; + max-height: 500px; + object-fit: cover; + } + + &:hover { + cursor: pointer; + } +} + +.upload-button { + background-color: $primary; + color: aliceblue; + margin-top: 10px; + padding: 5px 40px; + border-radius: 8px; + font-size: 1.15rem; + + &:hover { + cursor: pointer; + } +} + +// fix plus button for editorjs + +@include color-mode(light) { + .tab-contents { + background-color: $white; + } + .text-container { + background-color: $article-editor-body-light; + } +} + +@include color-mode(dark) { + .tab-contents { + background-color: $primary-bg-dark; + } + + .text-container { + background-color: $article-editor-body-dark; + } + + .ce-toolbar__actions--opened { + .ce-toolbar__plus, + .ce-toolbar__settings-btn { + color: white; + background-color: $article-editor-body-dark; + border-color: $article-editor-body-dark; + } + + .ce-toolbar__plus:hover, + .ce-toolbar__settings-btn:hover { + background-color: $primary-bg-dark; + border-color: $primary-bg-dark; + } + } +} diff --git a/frontend/src/pages/ArticleResultsPage/ArticleResultsPage.jsx b/frontend/src/pages/ArticleResultsPage/ArticleResultsPage.jsx new file mode 100644 index 0000000..34b8bc3 --- /dev/null +++ b/frontend/src/pages/ArticleResultsPage/ArticleResultsPage.jsx @@ -0,0 +1,108 @@ +import { useEffect, useState } from "react"; +import { useParams, useSearchParams } from "react-router-dom"; +import RelatedTags from "../../Components/ArticleResults/SideSections/RelatedTopicTags/RelatedTopicTags.jsx"; +import ArticleResultsList from "../../Components/ArticleResults/ArticleResultsList.jsx"; +import { Col, Container, Row } from "react-bootstrap"; +import "./ArticleResultsPage.scss"; +import { useLoadingSpinner } from "../../context/SpinnerContext.jsx"; +import { useToggleBookmark } from "../../hooks/useToggleBookmark.jsx"; +import { useAuthContext } from "../../hooks/useAuthContext.jsx"; +import toast from "react-hot-toast"; +const dummy_topic_tags = [ + { label: "Deep Learning" }, + { label: "Artifical Intelligence" }, + { label: "Computer Vision" }, + { label: "Data Science" }, +]; + +export default function ArticleResultsPage({}) { + const { showSpinner, hideSpinner } = useLoadingSpinner(); + + const [articles, setArticles] = useState(); + const [searchParams, setSearchParams] = useSearchParams(); + const [specificArticle, setSpecificArticle] = useState(); + const titleQuery = searchParams.get("title"); + + const user = JSON.parse(localStorage.getItem("user")); + + const { toggleBookmark } = useToggleBookmark(); + //bookmark toggler creator function, returns function that toggles bookmark for certain id depending on server response + const bookmarkTogglerCreator = (id) => async () => { + if (!user) { + toast.error("You must be logged in to bookmark articles."); + return; + } + let articleIndex = articles.findIndex((article) => article.id === id); + if (articleIndex == -1) return; + + let bookmarkArticle = articles[articleIndex]; + + showSpinner(); + let result = await toggleBookmark( + bookmarkArticle.id, + bookmarkArticle.isBookmarked + ); + await ((ms) => new Promise((resolve) => setTimeout(resolve, ms)))(250); + if (!result.error) { + setArticles((prev) => + prev.map((currArticle) => + currArticle.id === id + ? { ...currArticle, isBookmarked: !currArticle.isBookmarked } + : currArticle + ) + ); + } + hideSpinner(); + }; + + useEffect(() => { + const apiUrl = import.meta.env.VITE_API_URL; + + const fetchArticles = async () => { + try { + showSpinner(); + let res = await fetch(`${apiUrl}/api/articles/?title=${titleQuery}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: user ? `Bearer ${user?.token}` : "", + }, + }); + res = await res.json(); + let dataCopy = [...res]; + setArticles(dataCopy); + } catch (err) { + console.log(err); + } finally { + hideSpinner(); + } + }; + fetchArticles(); + }, [titleQuery, setSearchParams]); + + return ( + + + +

    + Displaying results for{" "} + "{titleQuery}" +

    + + + + + +
    +
    + ); +} diff --git a/frontend/src/pages/ArticleResultsPage/ArticleResultsPage.scss b/frontend/src/pages/ArticleResultsPage/ArticleResultsPage.scss new file mode 100644 index 0000000..d5ed740 --- /dev/null +++ b/frontend/src/pages/ArticleResultsPage/ArticleResultsPage.scss @@ -0,0 +1,48 @@ +@import "bootstrap/scss/bootstrap-grid.scss"; +@import "bootstrap/scss/grid"; +@import "bootstrap/scss/variables"; +@import "bootstrap/scss/mixins"; +@import "bootstrap/scss/functions"; +@import "../../scss/variables"; + +.article-results-title { + font-weight: 500; + font-size: calc(1.8rem + 0.8vw); + @include media-breakpoint-up(lg) { + padding-left: 20px; + } +} + +.article-results-title-query { + color: $primary; +} +.side-sections-container { + height: fit-content; + padding-bottom: 20px; + @include media-breakpoint-down(md) { + border-bottom-style: solid; + border-bottom-width: 1px; + margin-bottom: 10px; + } + @include media-breakpoint-up(md) { + border-left-style: solid; + border-left-width: 1px; + } +} + +@include color-mode(light) { + .article-results-title { + color: $primary-text-light; + } + .side-sections-container { + border-color: $divider-light; + } +} +@include color-mode(dark) { + .article-results-title { + color: $primary-text-dark; + } + .side-sections-container { + border-color: $divider-dark; + } +} diff --git a/frontend/src/pages/ArticleView.jsx b/frontend/src/pages/ArticleView.jsx index 4bcc953..ea0e74f 100644 --- a/frontend/src/pages/ArticleView.jsx +++ b/frontend/src/pages/ArticleView.jsx @@ -1,24 +1,29 @@ import { useEffect, useState } from "react"; import { useParams } from "react-router-dom"; import Article from "../Components/Article/Article.jsx"; +import { useGetArticleByID } from "../hooks/useGetArticleByID.jsx"; export default function ArticleView() { const [article, setArticle] = useState(); - const { name = "" } = useParams(); + const { articleID = "" } = useParams(); + const { getArticleByID } = useGetArticleByID(); useEffect(() => { - fetch(`http://localhost:3002/api/articles/${name}`) - .then((res) => res.json() - .then((data) => { - let article = data[0]; - console.log(data); - setArticle(article); - }) - ) - .catch((error) => { - console.error("error fetching data"); - }); - }, [name]); + const fetchArticle = async () => { + try { + let fetchedArticle = await getArticleByID(articleID); + console.log(fetchedArticle); + if (fetchedArticle) { + setArticle(fetchedArticle); + } + } catch (err) { + console.log(err); + console.log("Error fetching article "); + } + }; + + fetchArticle(); + }, [articleID]); return ( <> {/*conditional rendering based on data being fetched*/} diff --git a/frontend/src/pages/BasePage.jsx b/frontend/src/pages/BasePage.jsx index 3ba7496..3635c5d 100644 --- a/frontend/src/pages/BasePage.jsx +++ b/frontend/src/pages/BasePage.jsx @@ -8,7 +8,6 @@ export default function BasePage() {
    -