diff --git a/package-lock.json b/package-lock.json index 671b1384fac341a78c5aacd043a28bde7cb447ed..802c2332f59df2fd4b6353ace61ec7e7e98fc455 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,10 +13,15 @@ "@testing-library/user-event": "^13.5.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "firebase": "^11.0.2", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-icons": "^5.4.0", + "react-modal": "^3.16.1", "react-router-dom": "^6.28.0", "react-scripts": "5.0.1", + "styled-components": "^6.1.13", + "sweetalert2": "^11.14.5", "tailwind-merge": "^2.5.4", "web-vitals": "^2.1.4", "workbox-background-sync": "^6.6.0", @@ -2248,6 +2253,27 @@ "postcss-selector-parser": "^6.0.10" } }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.2.tgz", + "integrity": "sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.8.1" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", + "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz", + "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==", + "license": "MIT" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", @@ -2344,6 +2370,684 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@firebase/analytics": { + "version": "0.10.10", + "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.10.tgz", + "integrity": "sha512-Psdo7c9g2SLAYh6u1XRA+RZ7ab2JfBVuAt/kLzXkhKZL/gS2cQUCMsOW5p0RIlDPRKqpdNSmvujd2TeRWLKOkQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/installations": "0.6.11", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/analytics-compat": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.16.tgz", + "integrity": "sha512-Q/s+u/TEMSb2EDJFQMGsOzpSosybBl8HuoSEMyGZ99+0Pu7SIR9MPDGUjc8PKiCFQWDJ3QXxgqh1d/rujyAMbA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/analytics": "0.10.10", + "@firebase/analytics-types": "0.8.3", + "@firebase/component": "0.6.11", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/analytics-types": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@firebase/analytics-types/-/analytics-types-0.8.3.tgz", + "integrity": "sha512-VrIp/d8iq2g501qO46uGz3hjbDb8xzYMrbu8Tp0ovzIzrvJZ2fvmj649gTjge/b7cCCcjT0H37g1gVtlNhnkbg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app": { + "version": "0.10.16", + "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.10.16.tgz", + "integrity": "sha512-SUati2qH48gvVGnSsqMkZr1Iq7No52a3tJQ4itboSTM89Erezmw3v1RsfVymrDze9+KiOLmBpvLNKSvheITFjg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.10.2", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/app-check": { + "version": "0.8.10", + "resolved": "https://registry.npmjs.org/@firebase/app-check/-/app-check-0.8.10.tgz", + "integrity": "sha512-DWFfxxif/t+Ow4MmRUevDX+A3hVxm1rUf6y5ZP4sIomfnVCO1NNahqtsv9rb1/tKGkTeoVT40weiTS/WjQG1mA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/app-check-compat": { + "version": "0.3.17", + "resolved": "https://registry.npmjs.org/@firebase/app-check-compat/-/app-check-compat-0.3.17.tgz", + "integrity": "sha512-a/eadrGsY0MVCBPhrNbKUhoYpms4UKTYLKO7nswwSFVsm3Rw6NslQQCNLfvljcDqP4E7alQDRGJXjkxd/5gJ+Q==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check": "0.8.10", + "@firebase/app-check-types": "0.5.3", + "@firebase/component": "0.6.11", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", + "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-check-types": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-types/-/app-check-types-0.5.3.tgz", + "integrity": "sha512-hyl5rKSj0QmwPdsAxrI5x1otDlByQ7bvNvVt8G/XPO2CSwE++rmSVf3VEhaeOR4J8ZFaF0Z0NDSmLejPweZ3ng==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-compat": { + "version": "0.2.46", + "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.2.46.tgz", + "integrity": "sha512-9hSHWE5LMqtKIm13CnH5OZeMPbkVV3y5vgNZ5EMFHcG2ceRrncyNjG9No5XfWQw8JponZdGs4HlE4aMD/jxcFA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app": "0.10.16", + "@firebase/component": "0.6.11", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/app-types": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", + "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.8.1.tgz", + "integrity": "sha512-LX9N/Cf5Z35r5yqm2+5M3+2bRRe/+RFaa/+u4HDni7TA27C/Xm4XHLKcWcLg1BzjrS4zngSaBEOSODvp6RFOqQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@react-native-async-storage/async-storage": "^1.18.1" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, + "node_modules/@firebase/auth-compat": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.5.16.tgz", + "integrity": "sha512-YlYwJMBqAyv0ESy3jDUyshMhZlbUiwAm6B6+uUmigNDHU+uq7j4SFiDJEZlFFIz397yBzKn06SUdqutdQzGnCA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth": "1.8.1", + "@firebase/auth-types": "0.12.3", + "@firebase/component": "0.6.11", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", + "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-types": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/@firebase/auth-types/-/auth-types-0.12.3.tgz", + "integrity": "sha512-Zq9zI0o5hqXDtKg6yDkSnvMCMuLU6qAVS51PANQx+ZZX5xnzyNLEBO3GZgBUPsV5qIMFhjhqmLDxUqCbnAYy2A==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/component": { + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.6.11.tgz", + "integrity": "sha512-eQbeCgPukLgsKD0Kw5wQgsMDX5LeoI1MIrziNDjmc6XDq5ZQnuUymANQgAb2wp1tSF9zDSXyxJmIUXaKgN58Ug==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/data-connect": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@firebase/data-connect/-/data-connect-0.1.2.tgz", + "integrity": "sha512-Bcf29mntFCt5V7aceMe36wnkHrG7cwbMlUVbDHOlh2foQKx9VtSXEONw9r6FtL1sFobHVYOM5L6umX35f59m5g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.6.11", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/database": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.0.10.tgz", + "integrity": "sha512-sWp2g92u7xT4BojGbTXZ80iaSIaL6GAL0pwvM0CO/hb0nHSnABAqsH7AhnWGsGvXuEvbPr7blZylPaR9J+GSuQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.6.11", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.10.2", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.0.1.tgz", + "integrity": "sha512-IsFivOjdE1GrjTeKoBU/ZMenESKDXidFDzZzHBPQ/4P20ptGdrl3oLlWrV/QJqJ9lND4IidE3z4Xr5JyfUW1vg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/database": "1.0.10", + "@firebase/database-types": "1.0.7", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.7.tgz", + "integrity": "sha512-I7zcLfJXrM0WM+ksFmFdAMdlq/DFmpeMNa+/GNsLyFo5u/lX5zzkPzGe3srVWqaBQBY5KprylDGxOsP6ETfL0A==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.3", + "@firebase/util": "1.10.2" + } + }, + "node_modules/@firebase/firestore": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.7.5.tgz", + "integrity": "sha512-OO3rHvjC07jL2ITN255xH/UzCVSvh6xG8oTzQdFScQvFbcm1fjCL1hgAdpDZcx3vVcKMV+6ktr8wbllkB8r+FQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.10.2", + "@firebase/webchannel-wrapper": "1.0.3", + "@grpc/grpc-js": "~1.9.0", + "@grpc/proto-loader": "^0.7.8", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/firestore-compat": { + "version": "0.3.40", + "resolved": "https://registry.npmjs.org/@firebase/firestore-compat/-/firestore-compat-0.3.40.tgz", + "integrity": "sha512-18HopMN811KYBc9Ptpr1Rewwio0XF09FF3jc5wtV6rGyAs815SlFFw5vW7ZeLd43zv9tlEc2FzM0H+5Vr9ZRxw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/firestore": "4.7.5", + "@firebase/firestore-types": "3.0.3", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/firestore-types": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@firebase/firestore-types/-/firestore-types-3.0.3.tgz", + "integrity": "sha512-hD2jGdiWRxB/eZWF89xcK9gF8wvENDJkzpVFb4aGkzfEaKxVRD1kjz1t1Wj8VZEp2LCB53Yx1zD8mrhQu87R6Q==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/functions": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/@firebase/functions/-/functions-0.11.10.tgz", + "integrity": "sha512-TP+Dzebazhw6+GduBdWn1kOJRFH84G2z+BW3pNVfkpFRkc//+uT1Uw2+dLpMGSSBRG7FrcDG91vcPnOFCzr15w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.6.11", + "@firebase/messaging-interop-types": "0.2.3", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/functions-compat": { + "version": "0.3.16", + "resolved": "https://registry.npmjs.org/@firebase/functions-compat/-/functions-compat-0.3.16.tgz", + "integrity": "sha512-FL7EXehiiBisNIR7mlb0i+moyWKLVfcEJgh/Wq6ZV6BdrCObpCTz7w5EvuRIEFX5e9cNL2oWInKg8S5X4HtINg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/functions": "0.11.10", + "@firebase/functions-types": "0.6.3", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/functions-types": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@firebase/functions-types/-/functions-types-0.6.3.tgz", + "integrity": "sha512-EZoDKQLUHFKNx6VLipQwrSMh01A1SaL3Wg6Hpi//x6/fJ6Ee4hrAeswK99I5Ht8roiniKHw4iO0B1Oxj5I4plg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/installations": { + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.11.tgz", + "integrity": "sha512-w8fY8mw6fxJzsZM2ufmTtomopXl1+bn/syYon+Gpn+0p0nO1cIUEVEFrFazTLaaL9q1CaVhc3HmseRTsI3igAA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/util": "1.10.2", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/installations-compat": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@firebase/installations-compat/-/installations-compat-0.2.11.tgz", + "integrity": "sha512-SHRgw5LTa6v8LubmJZxcOCwEd1MfWQPUtKdiuCx2VMWnapX54skZd1PkQg0K4l3k+4ujbI2cn7FE6Li9hbChBw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/installations": "0.6.11", + "@firebase/installations-types": "0.5.3", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/installations-types": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@firebase/installations-types/-/installations-types-0.5.3.tgz", + "integrity": "sha512-2FJI7gkLqIE0iYsNQ1P751lO3hER+Umykel+TkLwHj6plzWVxqvfclPUZhcKFVQObqloEBTmpi2Ozn7EkCABAA==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/logger": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.4.4.tgz", + "integrity": "sha512-mH0PEh1zoXGnaR8gD1DeGeNZtWFKbnz9hDO91dIml3iou1gpOnLqXQ2dJfB71dj6dpmUjcQ6phY3ZZJbjErr9g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/messaging": { + "version": "0.12.14", + "resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.12.14.tgz", + "integrity": "sha512-cSGP34jJswFvME8tdMDkvJvW6T1jEekyMSyq84AMBZ0KEpJbDWuC9n4wKT2lxUm1jaL651iZnn6g51yCl77ICg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/installations": "0.6.11", + "@firebase/messaging-interop-types": "0.2.3", + "@firebase/util": "1.10.2", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/messaging-compat": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/@firebase/messaging-compat/-/messaging-compat-0.2.14.tgz", + "integrity": "sha512-r9weK8jTEA2aGiwy0IbMQPnzuJ0DHkOQaMxGJOlU2QZ1a7fh6RHpNtaoM+LKnn6u1NQgmAOWYNr9vezVQEm9zw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/messaging": "0.12.14", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/messaging-interop-types": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.3.tgz", + "integrity": "sha512-xfzFaJpzcmtDjycpDeCUj0Ge10ATFi/VHVIvEEjDNc3hodVBQADZ7BWQU7CuFpjSHE+eLuBI13z5F/9xOoGX8Q==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/performance": { + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/@firebase/performance/-/performance-0.6.11.tgz", + "integrity": "sha512-FlkJFeqLlIeh5T4Am3uE38HVzggliDIEFy/fErEc1faINOUFCb6vQBEoNZGaXvRnTR8lh3X/hP7tv37C7BsK9g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/installations": "0.6.11", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/performance-compat": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@firebase/performance-compat/-/performance-compat-0.2.11.tgz", + "integrity": "sha512-DqeNBy51W2xzlklyC7Ht9JQ94HhTA08PCcM4MDeyG/ol3fqum/+YgtHWQ2IQuduqH9afETthZqLwCZiSgY7hiA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/logger": "0.4.4", + "@firebase/performance": "0.6.11", + "@firebase/performance-types": "0.2.3", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/performance-types": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@firebase/performance-types/-/performance-types-0.2.3.tgz", + "integrity": "sha512-IgkyTz6QZVPAq8GSkLYJvwSLr3LS9+V6vNPQr0x4YozZJiLF5jYixj0amDtATf1X0EtYHqoPO48a9ija8GocxQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/remote-config": { + "version": "0.4.11", + "resolved": "https://registry.npmjs.org/@firebase/remote-config/-/remote-config-0.4.11.tgz", + "integrity": "sha512-9z0rgKuws2nj+7cdiqF+NY1QR4na6KnuOvP+jQvgilDOhGtKOcCMq5XHiu66i73A9kFhyU6QQ2pHXxcmaq1pBw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/installations": "0.6.11", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/remote-config-compat": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-compat/-/remote-config-compat-0.2.11.tgz", + "integrity": "sha512-zfIjpwPrGuIOZDmduukN086qjhZ1LnbJi/iYzgua+2qeTlO0XdlE1v66gJPwygGB3TOhT0yb9EiUZ3nBNttMqg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/logger": "0.4.4", + "@firebase/remote-config": "0.4.11", + "@firebase/remote-config-types": "0.3.3", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/remote-config-types": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-types/-/remote-config-types-0.3.3.tgz", + "integrity": "sha512-YlRI9CHxrk3lpQuFup9N1eohpwdWayKZUNZ/YeQ0PZoncJ66P32UsKUKqVXOaieTjJIOh7yH8JEzRdht5s+d6g==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/storage": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.13.4.tgz", + "integrity": "sha512-b1KaTTRiMupFurIhpGIbReaWev0k5O3ouTHkAPcEssT+FvU3q/1JwzvkX4+ZdB60Fc43Mbp8qQ1gWfT0Z2FP9Q==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/storage-compat": { + "version": "0.3.14", + "resolved": "https://registry.npmjs.org/@firebase/storage-compat/-/storage-compat-0.3.14.tgz", + "integrity": "sha512-Ok5FmXJiapaNAOQ8W8qppnfwgP8540jw2B8M0c4TFZqF4BD+CoKBxW0dRtOuLNGadLhzqqkDZZZtkexxrveQqA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/storage": "0.13.4", + "@firebase/storage-types": "0.8.3", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/storage-types": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@firebase/storage-types/-/storage-types-0.8.3.tgz", + "integrity": "sha512-+Muk7g9uwngTpd8xn9OdF/D48uiQ7I1Fae7ULsWPuKoCH3HU7bfFPhxtJYzyhjdniowhuDpQcfPmuNRAqZEfvg==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/util": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.10.2.tgz", + "integrity": "sha512-qnSHIoE9FK+HYnNhTI8q14evyqbc/vHRivfB4TgCIUOl4tosmKSQlp7ltymOlMP4xVIJTg5wrkfcZ60X4nUf7Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/vertexai": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@firebase/vertexai/-/vertexai-1.0.1.tgz", + "integrity": "sha512-f48MGSofhaS05ebpN7zMIv4tBqYf19pXr5/4njKtNZVLbjxUswDma0SuFDoO+IwgbdkhFxgtNctM+C1zfI/O1Q==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/component": "0.6.11", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/webchannel-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.3.tgz", + "integrity": "sha512-2xCRM9q9FlzGZCdgDMJwc0gyUkWFtkosy7Xxr6sFgQwn+wMNIWd7xIvYNauU1r64B5L5rsGKy/n9TKJ0aAFeqQ==", + "license": "Apache-2.0" + }, + "node_modules/@grpc/grpc-js": { + "version": "1.9.15", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.15.tgz", + "integrity": "sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.7.8", + "@types/node": ">=12.12.47" + }, + "engines": { + "node": "^8.13.0 || >=10.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.13", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.13.tgz", + "integrity": "sha512-AiXO/bfe9bmxBjxxtYxFAXGZvMaN5s8kO+jBHAJCON8rJoB5YS/D6X7ZNc6XQkuHNmyl4CYaMI1fJ/Gn27RGGw==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@grpc/proto-loader/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@grpc/proto-loader/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -2952,6 +3656,70 @@ } } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, "node_modules/@remix-run/router": { "version": "1.21.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.21.0.tgz", @@ -3905,6 +4673,12 @@ "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==" }, + "node_modules/@types/stylis": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.5.tgz", + "integrity": "sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw==", + "license": "MIT" + }, "node_modules/@types/testing-library__jest-dom": { "version": "5.14.9", "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.9.tgz", @@ -5302,6 +6076,15 @@ "node": ">= 6" } }, + "node_modules/camelize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", + "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-api": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", @@ -5820,6 +6603,15 @@ "postcss": "^8.4" } }, + "node_modules/css-color-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", + "license": "ISC", + "engines": { + "node": ">=4" + } + }, "node_modules/css-declaration-sorter": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.4.1.tgz", @@ -5961,6 +6753,17 @@ "resolved": "https://registry.npmjs.org/css-select-base-adapter/-/css-select-base-adapter-0.1.1.tgz", "integrity": "sha512-jQVeeRG70QI08vSTwf1jHxp74JoZsr2XSgETae8/xC8ovSnL2WF87GTLO86Sbwdt2lK4Umg4HnnwMO4YF3Ce7w==" }, + "node_modules/css-to-react-native": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", + "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", + "license": "MIT", + "dependencies": { + "camelize": "^1.0.0", + "css-color-keywords": "^1.0.0", + "postcss-value-parser": "^4.0.2" + } + }, "node_modules/css-tree": { "version": "1.0.0-alpha.37", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.0.0-alpha.37.tgz", @@ -7544,6 +8347,12 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/exenv": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.2.tgz", + "integrity": "sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw==", + "license": "BSD-3-Clause" + }, "node_modules/exit": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", @@ -7844,6 +8653,42 @@ "node": ">=8" } }, + "node_modules/firebase": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/firebase/-/firebase-11.0.2.tgz", + "integrity": "sha512-w4T8BSJpzdZA25QRch5ahLsgB6uRvg1LEic4BaC5rTD1YygroI1AXp+W+rbMnr8d8EjfAv6t4k8doULIjc1P8Q==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/analytics": "0.10.10", + "@firebase/analytics-compat": "0.2.16", + "@firebase/app": "0.10.16", + "@firebase/app-check": "0.8.10", + "@firebase/app-check-compat": "0.3.17", + "@firebase/app-compat": "0.2.46", + "@firebase/app-types": "0.9.3", + "@firebase/auth": "1.8.1", + "@firebase/auth-compat": "0.5.16", + "@firebase/data-connect": "0.1.2", + "@firebase/database": "1.0.10", + "@firebase/database-compat": "2.0.1", + "@firebase/firestore": "4.7.5", + "@firebase/firestore-compat": "0.3.40", + "@firebase/functions": "0.11.10", + "@firebase/functions-compat": "0.3.16", + "@firebase/installations": "0.6.11", + "@firebase/installations-compat": "0.2.11", + "@firebase/messaging": "0.12.14", + "@firebase/messaging-compat": "0.2.14", + "@firebase/performance": "0.6.11", + "@firebase/performance-compat": "0.2.11", + "@firebase/remote-config": "0.4.11", + "@firebase/remote-config-compat": "0.2.11", + "@firebase/storage": "0.13.4", + "@firebase/storage-compat": "0.3.14", + "@firebase/util": "1.10.2", + "@firebase/vertexai": "1.0.1" + } + }, "node_modules/flat-cache": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", @@ -10576,6 +11421,12 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -10601,6 +11452,12 @@ "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==" }, + "node_modules/long": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", + "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==", + "license": "Apache-2.0" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -12820,6 +13677,30 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/protobufjs": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz", + "integrity": "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -13101,11 +13982,45 @@ "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz", "integrity": "sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==" }, + "node_modules/react-icons": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.4.0.tgz", + "integrity": "sha512-7eltJxgVt7X64oHh6wSWNwwbKTCtMfK35hcjvJS0yxEAhPM8oUKdS3+kqaW1vicIltw+kR2unHaa12S9pPALoQ==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" }, + "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-modal": { + "version": "3.16.1", + "resolved": "https://registry.npmjs.org/react-modal/-/react-modal-3.16.1.tgz", + "integrity": "sha512-VStHgI3BVcGo7OXczvnJN7yT2TWHJPDXZWyI/a0ssFNhGZWsPmB8cF0z33ewDXq4VfYMO1vXgiv/g8Nj9NDyWg==", + "license": "MIT", + "dependencies": { + "exenv": "^1.2.0", + "prop-types": "^15.7.2", + "react-lifecycles-compat": "^3.0.0", + "warning": "^4.0.3" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18", + "react-dom": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18" + } + }, "node_modules/react-refresh": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", @@ -13994,6 +14909,12 @@ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, + "node_modules/shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -14557,6 +15478,68 @@ "webpack": "^5.0.0" } }, + "node_modules/styled-components": { + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.13.tgz", + "integrity": "sha512-M0+N2xSnAtwcVAQeFEsGWFFxXDftHUD7XrKla06QbpUMmbmtFBMMTcKWvFXtWxuD5qQkB8iU5gk6QASlx2ZRMw==", + "license": "MIT", + "dependencies": { + "@emotion/is-prop-valid": "1.2.2", + "@emotion/unitless": "0.8.1", + "@types/stylis": "4.2.5", + "css-to-react-native": "3.2.0", + "csstype": "3.1.3", + "postcss": "8.4.38", + "shallowequal": "1.1.0", + "stylis": "4.3.2", + "tslib": "2.6.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/styled-components" + }, + "peerDependencies": { + "react": ">= 16.8.0", + "react-dom": ">= 16.8.0" + } + }, + "node_modules/styled-components/node_modules/postcss": { + "version": "8.4.38", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", + "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "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.7", + "picocolors": "^1.0.0", + "source-map-js": "^1.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/styled-components/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "license": "0BSD" + }, "node_modules/stylehacks": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.1.tgz", @@ -14572,6 +15555,12 @@ "postcss": "^8.2.15" } }, + "node_modules/stylis": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.2.tgz", + "integrity": "sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg==", + "license": "MIT" + }, "node_modules/sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", @@ -14825,6 +15814,16 @@ "node": ">=4" } }, + "node_modules/sweetalert2": { + "version": "11.14.5", + "resolved": "https://registry.npmjs.org/sweetalert2/-/sweetalert2-11.14.5.tgz", + "integrity": "sha512-8MWk5uc/r6bWhiJWkUXyEuApfXAhSCZT8FFX7pZXL7YwaPxq+9Ynhi2dUzWkOFn9jvLjKj22CXuccZ+IHcnjvQ==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/limonte" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -15547,6 +16546,15 @@ "makeerror": "1.0.12" } }, + "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/watchpack": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", diff --git a/package.json b/package.json index a8aeba3db90e6f50697070e77ecf07e48459d6c1..9794b7a42808c520486595e401f3cb9faf266c11 100644 --- a/package.json +++ b/package.json @@ -8,10 +8,15 @@ "@testing-library/user-event": "^13.5.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "firebase": "^11.0.2", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-icons": "^5.4.0", + "react-modal": "^3.16.1", "react-router-dom": "^6.28.0", "react-scripts": "5.0.1", + "styled-components": "^6.1.13", + "sweetalert2": "^11.14.5", "tailwind-merge": "^2.5.4", "web-vitals": "^2.1.4", "workbox-background-sync": "^6.6.0", diff --git a/src/App.js b/src/App.js index af5e2cb370603b0c49f90b34f74755dacf59d445..ef2b121afbb5396d732cfb4fcee62e0eeee9575a 100644 --- a/src/App.js +++ b/src/App.js @@ -4,6 +4,7 @@ import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import LoginPage from "./pages/LoginPage"; import HomePage from "./pages/HomePage"; import ChattingListPage from "./pages/Chatting/ChattingListPage"; +import ChattingDetailPage from "./pages/Chatting/ChattingDetailPage"; import MyPage from "./pages/Mypage"; import HeaderNav from "./components/layout/HeaderNav"; import Footer from "./components/layout/Footer"; @@ -22,6 +23,7 @@ const App = () => { <Route path="/" element={<HomePage />} /> <Route path="/timetable" element={<SchedulePage />} /> <Route path="/chattinglist" element={<ChattingListPage />} /> + <Route path="/chat/chatRoom/:chatRoomId" element={<ChattingDetailPage />} /> <Route path="/mypage" element={<MyPage />} /> <Route path="/login" element={<LoginPage />} /> </Routes> diff --git a/src/components/ChattingDetail.css b/src/components/ChattingDetail.css new file mode 100644 index 0000000000000000000000000000000000000000..17942aca2836e4b6ff9e03833f3644385510cdf3 --- /dev/null +++ b/src/components/ChattingDetail.css @@ -0,0 +1,96 @@ +/* ChattingDetail.css */ +.chatting-detail-container { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + background-color: #ffffff; + min-height: 100vh; + padding: 1rem; + box-sizing: border-box; +} + +.chatting-header { + width: 100%; + background-color: #f8f9fa; + padding: 0.5rem 1rem; + display: flex; + align-items: center; + border-bottom: 1px solid #e9ecef; +} + +.chatting-header h1 { + font-size: 1.25rem; + font-weight: bold; + margin-left: 0.5rem; + color: #343a40; +} + +.chatting-messages { + flex: 1; + width: 100%; + overflow-y: auto; + padding: 1rem; + box-sizing: border-box; + background-color: #f1f3f5; + border-radius: 0.5rem; +} + +.chatting-message { + display: flex; + align-items: flex-start; + margin-bottom: 0.75rem; +} + +.chatting-message img { + width: 36px; + height: 36px; + border-radius: 50%; + margin-right: 0.5rem; +} + +.chatting-message .message-content { + background-color: #ffffff; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + max-width: 70%; +} + +.chatting-message.own .message-content { + background-color: #e0f7fa; + margin-left: auto; +} + +.chatting-input-container { + width: 100%; + display: flex; + align-items: center; + padding: 0.5rem; + background-color: #ffffff; + border-top: 1px solid #e9ecef; +} + +.chatting-input-container input { + flex: 1; + padding: 0.75rem; + font-size: 1rem; + border: 1px solid #ced4da; + border-radius: 0.5rem; + margin-right: 0.5rem; + outline: none; +} + +.chatting-input-container button { + background-color: #007bff; + color: #ffffff; + border: none; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + font-size: 1rem; + cursor: pointer; +} + +.chatting-input-container button:hover { + background-color: #0056b3; +} \ No newline at end of file diff --git a/src/components/ChattingDetail.jsx b/src/components/ChattingDetail.jsx new file mode 100644 index 0000000000000000000000000000000000000000..5f82d0200d484816229d3f2eec3b18418e82cc37 --- /dev/null +++ b/src/components/ChattingDetail.jsx @@ -0,0 +1,942 @@ +import { useParams, useLocation } from 'react-router-dom'; +import { useEffect, useState, useRef, useCallback, useMemo } from 'react'; +import './ChattingDetail.css'; +import styled, { keyframes } from 'styled-components'; +import { FaSearch, FaArrowUp, FaArrowDown } from 'react-icons/fa'; +import Button from "../components/Button"; +import ChattingNoticeDetailModal from '../components/ChattingNoticeDetailModal'; +import ChattingNoticeListModal from '../components/ChattingNoticeListModal'; + +// 흔들리는 애니메이션을 위한 keyframes 정의 +const shakeAnimation = keyframes` + 0% { transform: translateY(0); } + 25% { transform: translateY(-5px); } + 50% { transform: translateY(0); } + 75% { transform: translateY(5px); } + 100% { transform: translateY(0); } +`; + +const ChatRoomContainer = styled.div` + display: flex; + flex-direction: column; + height: 100vh; /* 전체 화면 높이 */ + width: 100%; + overflow: hidden; /* 부모 요소의 스크롤 제거 */ +`; + +const ChatRoomHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px; + border-bottom: 1px solid #ddd; +`; + +const SearchBar = styled.div` + display: flex; + align-items: center; + padding: 10px; + border-bottom: 1px solid #ddd; + + input { + width: 100%; + padding: 10px; + border-radius: 5px; + border: 1px solid #ddd; + } + + .arrow-buttons { + display: flex; + align-items: center; + margin-left: 10px; + + svg { + cursor: pointer; + margin-left: 5px; + } + } +`; + +const ChatRoomMessages = styled.div` + flex: 1; + overflow-y: auto; /* 메시지 영역만 스크롤 가능 */ + padding: 10px; + margin-top: 10px; /* 검색 바와 메시지 사이 여백 */ +`; + +const MessageContainer = styled.div` + display: flex; + justify-content: ${(props) => (props.isMine ? 'flex-end' : 'flex-start')}; + margin-bottom: 10px; + padding: 10px; + border-radius: 5px; + border: ${(props) => (props.highlighted ? '2px solid blue' : 'none')}; + transition: background-color 0.3s ease, border 0.3s ease; + animation: ${(props) => (props.highlighted ? shakeAnimation : 'none')} 0.5s ease; +`; + +const MessageBubble = styled.div` + max-width: 60%; + padding: 10px; + border-radius: 10px; + background-color: ${(props) => (props.isMine ? '#dcf8c6' : '#f9f9f9')}; + text-align: ${(props) => (props.isMine ? 'right' : 'left')}; + border: ${(props) => (props.highlighted ? '2px solid blue' : 'none')}; + word-wrap: break-word; + transition: background-color 0.3s ease, border 0.3s ease; + box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.1); + animation: ${(props) => (props.highlighted ? shakeAnimation : 'none')} 0.5s ease; +`; + +const CenteredMessage = styled.div` + text-align: center; + font-size: 0.9em; + color: #888; + margin: 20px 0; + background-color: #f0f0f0; + padding: 10px; + border-radius: 10px; + max-width: 50%; + margin-left: auto; + margin-right: auto; +`; + +const MessageTimestamp = styled.div` + font-size: 0.8em; + color: #888; + text-align: ${(props) => (props.isMine ? 'right' : 'left')}; +`; + +const ChatRoomInput = styled.div` + display: flex; + padding: 10px; + border-top: 1px solid #ddd; + + input { + flex: 1; + padding: 10px; + border-radius: 5px; + border: 1px solid #ddd; + margin-right: 10px; + } + + button { + padding: 10px; + border-radius: 5px; + background-color: #007bff; + color: white; + border: none; + cursor: pointer; + } + + button:hover { + background-color: #0056b3; + } +`; + +const FixedSearchBar = styled(SearchBar)` + position: sticky; + top: 0; /* 화면 상단에 고정 */ + z-index: 10; /* 다른 요소 위에 표시 */ + background-color: white; /* 배경색 지정 */ + border-bottom: 1px solid #ddd; +`; + +const NoticeContainer = styled.div` + background-color: ${({ isCollapsed }) => (isCollapsed ? "transparent" : "#f8f9fa")}; + padding: ${({ isCollapsed }) => (isCollapsed ? "0" : "10px")}; + border-bottom: ${({ isCollapsed }) => (isCollapsed ? "none" : "1px solid #ddd")}; + position: relative; /* 확성기 위치 조정을 위해 사용 */ +`; + +const NoticeMessage = styled.div` + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; /* 최대 3줄까지만 표시 */ + white-space: normal; + height: calc(1.3em * 3); /* 3줄 높이로 제한 */ + cursor: pointer; + margin-bottom: -30px; /* 메시지와 작성자 사이 간격 */ +`; + +const NoticeSender = styled.div` + font-size: 0.875rem; + color: #888; + text-align: left; +`; + +const NoticeActions = styled.div` + display: flex; + justify-content: flex-end; + margin-top: 10px; + gap: 10px; + + button { + padding: 5px 10px; + border: none; + background-color: #e7e7e7; + color: #555; + border-radius: 5px; + cursor: pointer; + font-size: 0.9em; + } + + button:hover { + background-color: #d6d6d6; + } +`; + +function ChattingDetail() { + const { chatRoomId } = useParams(); + const location = useLocation(); + const { nickname, chatRoomName } = location.state; + + // const [notice, setNotice] = useState(null); // 공지 메시지 + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [currentSearchIndex, setCurrentSearchIndex] = useState(0); + const [isSearching, setIsSearching] = useState(false); + const [loggedInUser, setLoggedInUser] = useState(nickname); + const [chatUnread, setChatUnread] = useState({}); + // const [isScrolledToBottom, setIsScrolledToBottom] = useState(true); + const [notice, setNotice] = useState(null); // 공지 메시지 + const [isNoticeVisible, setIsNoticeVisible] = useState(true); // 공지 표시 여부 + const [isNoticeCollapsed, setIsNoticeCollapsed] = useState(false); // 공지 접힘 여부 + const [isNoticeModalOpen, setIsNoticeModalOpen] = useState(false); + const [isNoticeDetailModalOpen, setIsNoticeDetailModalOpen] = useState(false); // 공지 상세 모달 상태 + const [selectedNotice, setSelectedNotice] = useState(null); // 선택된 공지 + const [notices, setNotices] = useState([]); // 공지사항 목록 + + // const chatRoomMessagesRef = useRef(null); // 메시지 컨테이너 참조 + const ws = useRef(null); + const messagesEndRef = useRef(null); + const searchInputRef = useRef(null); + const highlightedMessageRef = useRef(null); + const isTabActiveRef = useRef(true); // useRef로 상태 초기화 + + let reconnectAttempts = 0; + const MAX_RECONNECT_ATTEMPTS = 5; + // let isTabActive = true; // 브라우저 탭 상태를 저장 + + const lastReadLogIdRef = useRef(null); + + const chatUnreadSortArray = useMemo(() => { + return Object.entries(chatUnread).sort(([key1], [key2]) => { + return key1.localeCompare(key2); + }); + }, [chatUnread]); + + const toggleSearchBar = () => { + setIsSearching((prevState) => !prevState); + }; + + const unreadCount = useCallback( + (logId) => { + if (chatUnreadSortArray.length === 0) { + return 0; + } + + for (let i = 0; i < chatUnreadSortArray.length; i++) { + if (logId <= chatUnreadSortArray[i][1]) { + return i === 0 ? 0 : Number(chatUnreadSortArray[i - 1][0]); + } + } + + return Number(chatUnreadSortArray[chatUnreadSortArray.length - 1][0]); + }, + [chatUnreadSortArray] + ); + + const handleNoticeClick = () => { + setSelectedNotice(notice); // 현재 공지사항을 선택 + setIsNoticeDetailModalOpen(true); // 상세 모달 열기 + }; + + const closeNoticeDetailModal = () => { + setIsNoticeDetailModalOpen(false); // 상세 모달 닫기 + }; + + const handleNoticeCollapse = () => { + setIsNoticeCollapsed(true); // 공지를 접음 + }; + + const handleMegaphoneClick = () => { + setIsNoticeCollapsed(false); // 공지를 펼침 + }; + + const handleDismissNotice = () => { + const dismissedNotices = JSON.parse(localStorage.getItem('dismissedNotices')) || []; + + // 현재 공지사항이 로컬 스토리지에 추가되도록 처리 + if (notice && !dismissedNotices.includes(notice.message)) { + dismissedNotices.push(notice.message); + localStorage.setItem('dismissedNotices', JSON.stringify(dismissedNotices)); + } + + setIsNoticeVisible(false); // 공지를 숨김 + }; + + const fetchUnreadCounts = async () => { + try { + const response = await fetch(`http://localhost:8080/api/chat/unread-count/${chatRoomId}`); + if (response.ok) { + const data = await response.json(); + setChatUnread(data); + } else { + console.error('Failed to fetch unread counts'); + } + } catch (error) { + console.error('Error fetching unread counts:', error); + } + }; + + /// 합친 코드 + const updateUserStatusAndLogId = async (status) => { + let logId = null; + // isOnline이 false일 경우 마지막 메시지의 logId를 가져옴 + // isOnline이 false로 전환될 때 마지막 메시지가 없으면 가장 최근 메시지의 logId를 설정 + if (!status) { + if (messages.length > 0) { + logId = messages[messages.length - 1]._id; // 가장 최근 메시지의 logId로 설정 + } else { + logId = lastReadLogIdRef.current; // 마지막 읽은 logId를 그대로 유지 + } + } + + try { + const response = await fetch('http://localhost:8080/api/chat/update-status-and-logid', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + chatRoomId, + nickname, + isOnline: status, + logId: logId, // 상태에 따라 logId도 함께 보냄 + }), + }); + + if (!response.ok) { + throw new Error('Failed to update status and logId'); + } + + console.log(`${nickname} : 상태업데이트 to ${status}, logId updated to ${logId} for `); + } catch (err) { + console.error('Error updating status and logId:', err); + } + }; + + const handleRightClick = async (e, messageData) => { + e.preventDefault(); + if (window.confirm("이 메시지를 공지로 설정하시겠습니까?")) { + try { + + const noticeMessage = { + type: 'notice', // 메시지 유형: 공지사항 설정 + chatRoomId: chatRoomId, // 현재 채팅방 ID + nickname: loggedInUser, // 공지 설정한 사용자 닉네임 + text: messageData.message, // 공지 내용 + }; + + // WebSocket을 통해 서버로 메시지 전송 + ws.current.send(JSON.stringify(noticeMessage)); + console.log('공지사항 설정 메시지 전송:', noticeMessage); + + + const response = await fetch( + `http://localhost:8080/api/chat/${chatRoomId}/notices`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + sender: messageData.sender, + message: messageData.message, + }), + } + ); + + if (response.ok) { + const newNotice = await response.json(); + setNotice(newNotice); // 공지를 로컬 상태로 업데이트 + } else { + console.error('Failed to set notice'); + } + } catch (error) { + console.error('Error setting notice:', error); + } + } + }; + + // // 새로운 메시지가 왔을 때만 스크롤 + // useEffect(() => { + // if (messages.length > 0) { + // const lastMessage = messages[messages.length - 1]; + // if (lastMessage.type === 'message') { + // scrollToBottom(); + // } + // } + // }, [messages]); // messages가 변경될 때만 실행 + + // const scrollToBottom = () => { + // console.log("바닥 스크롤") + // if (messagesEndRef.current) { + // messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }); + // } + // }; + + const fetchLatestNotice = async () => { + try { + const response = await fetch(`http://localhost:8080/api/chat/${chatRoomId}/notices/latest`); + if (response.ok) { + const latestNotice = await response.json(); + + const dismissedNotices = JSON.parse(localStorage.getItem('dismissedNotices')) || []; + const isDismissed = dismissedNotices.includes(latestNotice.message); + + // 새로운 공지라면 표시하고, 숨겨진 공지가 아니라면 표시 + setNotice(latestNotice); + setIsNoticeVisible(!isDismissed); + } else if (response.status === 404) { + // 공지가 없는 경우 처리 + setNotice(null); + setIsNoticeVisible(false); + } else { + console.error('Failed to fetch latest notice'); + } + } catch (error) { + console.error('Error fetching latest notice:', error); + } + }; + + const scrollToHighlightedMessage = () => { + if (highlightedMessageRef.current) { + highlightedMessageRef.current.scrollIntoView({ + behavior: 'smooth', + block: 'center' + }); + } + }; + + const joinRoom = () => { + if (ws.current) { + ws.current.close(); // 기존 WebSocket 연결 종료 + } + + ws.current = new WebSocket('ws://localhost:8081'); + ws.current.onopen = () => { + if (ws.current.isTimedOut) { + console.log(`타임아웃된 클라이언트의 재연결을 차단: ${nickname}`); + ws.current.close(); + return; + } + + reconnectAttempts = 0; // 재연결 성공 시 시도 횟수 초기화 + const joinMessage = JSON.stringify({ + type: 'join', + chatRoomId, + nickname, + }); + ws.current.send(joinMessage); + console.log(`(클라이언트) WebSocket 연결 완료 - 채팅방: ${chatRoomId}, 닉네임: ${nickname}`); + }; + + ws.current.onmessage = (event) => { + const messageData = JSON.parse(event.data); + console.log("받은 메시지", messageData); + + if (messageData.type === 'status') { + const { nickname, isOnline } = messageData; + if (!isOnline) { + console.log(`${nickname}님이 오프라인 상태로 전환되었습니다.`); + } + fetchUnreadCounts(); + return; + } + + if (messageData.type === 'notice') { + // 공지사항 변경 이벤트 처리 + console.log('새 공지사항 알림 수신:', messageData); + setNotice({ + sender: messageData.sender, + message: messageData.message, + }); + setIsNoticeVisible(true); // 공지를 표시하도록 설정 + fetchLatestNotice(); // 최신 공지사항 업데이트 + } else if (messageData.type === 'previousMessages') { + // setMessages((prevMessages) => [...prevMessages, ...messageData.messages]); + setMessages((prevMessages) => { + const messageIds = new Set(prevMessages.map((msg) => msg._id)); // 기존 메시지의 ID 저장 + const newMessages = messageData.messages.filter((msg) => !messageIds.has(msg._id)); // 중복 제거 + return [...prevMessages, ...newMessages]; + }); + } else { + // setMessages((prevMessages) => [...prevMessages, messageData]); + setMessages((prevMessages) => { + const messageIds = new Set(prevMessages.map((msg) => msg._id)); // 기존 메시지의 ID 저장 + if (!messageIds.has(messageData._id)) { // 새 메시지가 중복되지 않으면 추가 + return [...prevMessages, messageData]; + } + return prevMessages; + }); + + lastReadLogIdRef.current = messageData._id; + // messages 배열에 메시지가 추가된 후에 logId를 업데이트 + const lastMessage = messageData; // 새로 받은 메시지 + lastReadLogIdRef.current = lastMessage._id; + + console.log('Received message logId:', lastReadLogIdRef.current); + if (!chatUnreadSortArray.length || chatUnreadSortArray[chatUnreadSortArray.length - 1][1] <= messageData._id) { + fetchUnreadCounts(chatRoomId); + } + } + updateLastReadAt(); + }; + + ws.current.onclose = async (event) => { + console.warn('(클라이언트)WebSocket 연결이 종료되었습니다.', event); + + // 탭이 활성화된 상태에서만 재연결 시도 + if (isTabActiveRef.current && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { + setTimeout(() => { + console.log(`WebSocket 재연결 시도 (${reconnectAttempts + 1}/${MAX_RECONNECT_ATTEMPTS})`); + reconnectAttempts++; + joinRoom(); // 재연결 시도 + }, 2000); // 2초 후 재연결 + } else if (!isTabActiveRef.current) { + console.log('브라우저 탭이 비활성화 상태입니다. WebSocket 재연결을 시도하지 않습니다.'); + } else { + console.error('WebSocket 재연결 실패: 최대 시도 횟수를 초과했습니다.'); + } + }; + + ws.current.onerror = (error) => { + console.error('WebSocket 오류:', error); + }; + }; + + const updateLastReadAt = async () => { + try { + const response = await fetch('http://localhost:8080/api/chat/update-read-status', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ chatRoomId, nickname }), + }); + + if (!response.ok) { + throw new Error('Failed to update lastReadAt'); + } + + console.log('lastReadAt updated for', nickname); + } catch (err) { + console.error('Error updating lastReadAt:', err); + } + + }; + + // // 채팅방에서 퇴장 + // const leaveRoom = () => { + // const leaveMessage = JSON.stringify({ + // type: 'leave', + // chatRoomId, + // nickname, + // }); + // try { + // ws.current.send(leaveMessage); // 서버로 퇴장 메시지 전송 + // console.log('퇴장 메시지 전송:', leaveMessage); + // ws.current.close(); // 웹소켓 연결 종료 + // } catch (error) { + // console.error('퇴장 메시지 전송 오류:', error); + // } + // }; + + const handleSearch = (e) => { + if (e.key === 'Enter') { + const results = messages.filter((message) => message.message.includes(searchTerm)); + setSearchResults(results); + setCurrentSearchIndex(results.length > 0 ? results.length - 1 : -1); + setIsSearching(true); + setTimeout(() => { + scrollToHighlightedMessage(); + }, 100); + } + }; + + const handleArrowDown = () => { + if (searchResults.length > 0 && currentSearchIndex < searchResults.length - 1) { + setCurrentSearchIndex((prevIndex) => prevIndex + 1); + setTimeout(() => { + scrollToHighlightedMessage(); + }, 0); + } else { + alert('더 이상 검색결과가 없습니다.'); + } + }; + + const handleArrowUp = () => { + if (searchResults.length > 0 && currentSearchIndex > 0) { + setCurrentSearchIndex((prevIndex) => prevIndex - 1); + setTimeout(() => { + scrollToHighlightedMessage(); + }, 0); + } else { + alert('더 이상 검색결과가 없습니다.'); + } + }; + + const sendMessage = () => { + if (input.trim()) { + const message = JSON.stringify({ + type: 'message', + chatRoomId, + sender: loggedInUser, + nickname, + text: input, + }); + + try { + ws.current.send(message); + console.log('전송한 메시지:', message); + updateLastReadAt(); + } catch (error) { + console.error('메시지 전송 오류:', error); + } + + setInput(''); + } else { + alert('메시지를 입력하세요.'); + } + }; + + useEffect(() => { + if (isSearching && searchInputRef.current) { + searchInputRef.current.focus(); + } + }, [isSearching]); + + useEffect(() => { + let heartbeatInterval; + + const sendHeartbeat = () => { + if (ws.current && ws.current.readyState === WebSocket.OPEN) { + const heartbeatMessage = JSON.stringify({ + type: 'heartbeat', + chatRoomId, + nickname, + }); + ws.current.send(heartbeatMessage); + // console.log('Heartbeat sent:', heartbeatMessage); + } + }; + + const startHeartbeat = () => { + // 5초 간격으로 Heartbeat 전송 + heartbeatInterval = setInterval(sendHeartbeat, 5000); + }; + + const stopHeartbeat = () => { + clearInterval(heartbeatInterval); + }; + + joinRoom(); + // WebSocket 연결 시 Heartbeat 시작 + startHeartbeat(); + + updateLastReadAt(); + + // 탭 상태가 변경되면 Heartbeat 중단/재개 + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + stopHeartbeat(); + } else if (document.visibilityState === 'visible') { + startHeartbeat(); + } + }); + + return () => { + if (ws.current) ws.current.close(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chatRoomId]); + + useEffect(() => { + // eslint-disable-next-line react-hooks/exhaustive-deps + const handleVisibilityChange = () => { + if (document.visibilityState === 'hidden') { // 브라우저 탭이 비활성화되었을 때 + // isTabActive = false; // 탭 비활성화 상태로 설정 + isTabActiveRef.current = false; // 탭 비활성화 상태로 설정 + updateUserStatusAndLogId(false); + if (ws.current) { + ws.current.close(); // WebSocket 연결 종료 + console.log('브라우저 탭 비활성화: WebSocket 연결 종료'); + } + } else if (document.visibilityState === 'visible') { // 브라우저 탭이 다시 활성화되었을 때 + // isTabActive = true; // 탭 활성화 상태로 설정 + isTabActiveRef.current = false; // 탭 비활성화 상태로 설정 + updateUserStatusAndLogId(true); + if (!ws.current || ws.current.readyState === WebSocket.CLOSED) { + console.log('브라우저 탭 활성화: WebSocket 연결 재시작'); + joinRoom(); // WebSocket 재연결 시도 + } + } + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chatRoomId]); + + useEffect(() => { + if (searchResults.length > 0) { + scrollToHighlightedMessage(); + } + }, [currentSearchIndex, searchResults.length]); + + useEffect(() => { + console.log('chatRoomId:', chatRoomId); + console.log('nickname:', nickname); + setLoggedInUser(nickname); + }, [chatRoomId, nickname]); + + useEffect(() => { + fetchLatestNotice(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chatRoomId]); // 채팅방 ID가 변경될 때마다 공지사항 업데이트 + + useEffect(() => { + // 공지사항 목록 가져오기 + const fetchNotices = async () => { + try { + const response = await fetch(`http://localhost:8080/api/chat/${chatRoomId}/notices`); + if (response.ok) { + const data = await response.json(); + setNotices(data); // 공지사항 목록 업데이트 + } else { + console.error('Failed to fetch notices'); + } + } catch (error) { + console.error('Error fetching notices:', error); + } + }; + fetchNotices(); + }, [chatRoomId]); + + useEffect(() => { + // 공지가 변경되면 렌더링 + console.log('공지사항 업데이트:', notice); + }, [notice]); + + useEffect(() => { + const handleBeforeUnload = () => { + updateUserStatusAndLogId(false); + if (ws.current) { + ws.current.close(); // WebSocket 연결 종료 + } + }; + + window.addEventListener('beforeunload', handleBeforeUnload); // 브라우저 종료/새로고침 이벤트 리스너 추가 + + return () => { + console.log("페이지 이동시 상태 업데이트"); + updateUserStatusAndLogId(false); // 페이지 이동 시 상태 업데이트 + window.removeEventListener('beforeunload', handleBeforeUnload); // 컴포넌트 언마운트 시 이벤트 리스너 제거 + if (ws.current) { + ws.current.close(); // WebSocket 연결 종료 + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chatRoomId]); // chatRoomId 변경 또는 컴포넌트 언마운트 시 실행 + + const highlightSearchTerm = (message) => { + if (!searchTerm) return message; + + const regex = new RegExp(`(${searchTerm})`, 'gi'); + const parts = message.split(regex); + + return parts.map((part, index) => + part.toLowerCase() === searchTerm.toLowerCase() ? ( + <span key={index} style={{ backgroundColor: 'yellow', fontWeight: 'bold' }}> + {part} + </span> + ) : ( + part + ) + ); + }; + + return ( + <ChatRoomContainer> + <ChatRoomHeader> + <h1 className="heading-1 text-gray-800">{chatRoomName}</h1> + {/* 검색 버튼 */} + <Button + size="lg" + theme="pink" + icon={<FaSearch />} + onClick={toggleSearchBar} + > + + </Button> + </ChatRoomHeader> + + {/* 공지사항 */} + {isNoticeVisible && notice && ( + <NoticeContainer isCollapsed={isNoticeCollapsed}> + {!isNoticeCollapsed ? ( + <> + <NoticeMessage isCollapsed={isNoticeCollapsed} onClick={handleNoticeClick}> + 📢 {notice?.message} + </NoticeMessage> + <NoticeSender> + {notice?.sender} + </NoticeSender> + <NoticeActions> + <button onClick={handleNoticeCollapse}>접어두기</button> + <button onClick={handleDismissNotice}>다시 열지 않음</button> + </NoticeActions> + </> + ) : ( + <Button + size="lg" + theme="mix" + onClick={handleMegaphoneClick} + style={{ + position: "absolute", + bottom: "-100px", + right: "30px", + zIndex: "20", + }} + > + 📢 + </Button> + )} + </NoticeContainer> + )} + + {/* 공지사항 상세 모달 */} + {isNoticeDetailModalOpen && ( + <ChattingNoticeDetailModal + initialNotice={selectedNotice} // 처음 표시할 공지사항 + notices={notices} + onClose={closeNoticeDetailModal} + onSelectNotice={(notice) => setSelectedNotice(notice)} + /> + )} + + {/* 공지사항 목록 모달 */} + {isNoticeModalOpen && ( + <ChattingNoticeListModal + notices={notices} + onClose={() => setIsNoticeModalOpen(false)} + onNoticeClick={(notice) => { + setSelectedNotice(notice); + setIsNoticeDetailModalOpen(true); + }} + /> + )} + + {isSearching && ( + <FixedSearchBar> + <input + ref={searchInputRef} + type="text" + value={searchTerm} + onChange={(e) => setSearchTerm(e.target.value)} + onKeyPress={handleSearch} + placeholder="대화 내용 검색" + /> + <div className="arrow-buttons"> + <FaArrowUp + onClick={handleArrowUp} + size={20} + style={{ pointerEvents: currentSearchIndex > 0 ? 'auto' : 'none', opacity: currentSearchIndex > 0 ? 1 : 0.5 }} + /> + <FaArrowDown + onClick={handleArrowDown} + size={20} + style={{ + pointerEvents: currentSearchIndex < searchResults.length - 1 ? 'auto' : 'none', + opacity: currentSearchIndex < searchResults.length - 1 ? 1 : 0.5, + }} + /> + </div> + {searchResults.length === 0 && ( + <p style={{ color: 'gray', fontSize: '0.9em', textAlign: 'center', marginTop: '10px' }}> + 더 이상 검색 결과가 없습니다. + </p> + )} + </FixedSearchBar> + )} + + <ChatRoomMessages> + {messages.length === 0 ? ( + <p>메시지가 없습니다.</p> + ) : ( + messages.map((messageData, index) => { + if (messageData.type === "join" || messageData.type === "leave") { + return ( + <CenteredMessage key={index}>{messageData.message}</CenteredMessage> + ); + } + + return ( + <MessageContainer + key={index} + isMine={messageData.sender === loggedInUser} + highlighted={searchResults[currentSearchIndex] === messageData} + ref={ + searchResults[currentSearchIndex] === messageData + ? highlightedMessageRef + : null + } + > + <MessageBubble + isMine={messageData.sender === loggedInUser} + onContextMenu={(e) => handleRightClick(e, messageData)} + > + <div> + {messageData.sender !== loggedInUser && ( + <strong>{messageData.sender}</strong> + )} + {highlightSearchTerm(messageData.message)} + </div> + <MessageTimestamp isMine={messageData.sender === loggedInUser}> + {new Date(messageData.timestamp).toLocaleTimeString()} + <span>{` (${unreadCount( + messageData._id + )}명이 읽지 않음)`}</span> + </MessageTimestamp> + </MessageBubble> + </MessageContainer> + ); + }) + )} + <div ref={messagesEndRef}></div> + </ChatRoomMessages> + + <ChatRoomInput> + <input + type="text" + value={input} + onChange={(e) => setInput(e.target.value)} + placeholder="메시지를 입력하세요" + onKeyPress={(e) => e.key === "Enter" && sendMessage()} + /> + <button onClick={sendMessage}>전송</button> + </ChatRoomInput> + </ChatRoomContainer> + ); +} + +export default ChattingDetail; \ No newline at end of file diff --git a/src/components/ChattingList.jsx b/src/components/ChattingList.jsx new file mode 100644 index 0000000000000000000000000000000000000000..4a2b3a0bd5d3cb87472135148f89d2819df7aff3 --- /dev/null +++ b/src/components/ChattingList.jsx @@ -0,0 +1,176 @@ +import React, { useState, useEffect, useRef, useCallback } from "react"; +import { useNavigate } from "react-router-dom"; + +function ChattingList() { + const [rooms, setRooms] = useState([]); + const [joinedRooms, setJoinedRooms] = useState([]); + const [unreadCounts, setUnreadCounts] = useState({}); + const [nickname] = useState(localStorage.getItem("nickname") || ""); + const navigate = useNavigate(); + const ws = useRef(null); + + // WebSocket 연결 및 실시간 업데이트 + const setupWebSocket = () => { + ws.current = new WebSocket('ws://localhost:8081'); // WebSocket 연결 + + ws.current.onopen = () => { + console.log('WebSocket 연결 성공'); + }; + + ws.current.onmessage = (event) => { + try { + const messageData = JSON.parse(event.data); + + if (messageData.type === 'message') { + const { chatRoomId, sender, message, timestamp } = messageData; + + // 마지막 메시지 업데이트 + setRooms((prevRooms) => + prevRooms.map((room) => + room.chatRoomId === chatRoomId + ? { + ...room, + lastMessage: { sender, message, timestamp }, + } + : room + ) + ); + + // 읽지 않은 메시지 개수 업데이트 + setUnreadCounts((prevUnreadCounts) => ({ + ...prevUnreadCounts, + [chatRoomId]: (prevUnreadCounts[chatRoomId] || 0) + 1, + })); + } + } catch (error) { + console.error('Error parsing WebSocket message:', error); + } + }; + + ws.current.onclose = () => { + console.log('("채팅 목록) WebSocket 연결이 종료되었습니다.'); + }; + + ws.current.onerror = (error) => { + console.error('WebSocket 오류:', error); + }; + }; + + const fetchRooms = async () => { + try { + const response = await fetch("http://localhost:8080/api/chat/rooms"); + if (!response.ok) throw new Error("Failed to fetch rooms"); + const data = await response.json(); + setRooms(data); + } catch (err) { + console.error("Error fetching rooms:", err); + } + }; + + // const fetchUnreadMessages = async () => { + // try { + // const response = await fetch(`http://localhost:8080/api/chat/unread-messages/${nickname}`); + // if (!response.ok) throw new Error("Failed to fetch unread messages"); + // const data = await response.json(); + // const joinedChatRoomIds = data.map((chatRoom) => chatRoom.chatRoomId); + // const unreadCountsMap = data.reduce((acc, chatRoom) => { + // acc[chatRoom.chatRoomId] = chatRoom.unreadCount; + // return acc; + // }, {}); + // setJoinedRooms(joinedChatRoomIds); + // setUnreadCounts(unreadCountsMap); + // } catch (err) { + // console.error("Error fetching unread messages:", err); + // } + // }; + const fetchUnreadMessages = useCallback(async () => { + try { + const response = await fetch(`http://localhost:8080/api/chat/unread-messages/${nickname}`); + if (!response.ok) throw new Error("Failed to fetch unread messages"); + const data = await response.json(); + const joinedChatRoomIds = data.map((chatRoom) => chatRoom.chatRoomId); + const unreadCountsMap = data.reduce((acc, chatRoom) => { + acc[chatRoom.chatRoomId] = chatRoom.unreadCount; + return acc; + }, {}); + setJoinedRooms(joinedChatRoomIds); + setUnreadCounts(unreadCountsMap); + } catch (err) { + console.error("Error fetching unread messages:", err); + } + }, [nickname]); + + const joinRoom = (chatRoomId, chatRoomName) => { + if (!nickname.trim()) { + alert("닉네임을 입력하세요."); + return; + } + setUnreadCounts((prevUnreadCounts) => ({ + ...prevUnreadCounts, + [chatRoomId]: 0, + })); + navigate(`/chat/chatRoom/${chatRoomId}`, { state: { nickname, chatRoomId, chatRoomName } }); + }; + + useEffect(() => { + console.log("Unread counts updated:", unreadCounts); + }, [unreadCounts]); + + useEffect(() => { + fetchRooms(); + if (nickname) { + fetchUnreadMessages(); + } + setupWebSocket(); // WebSocket 연결 설정 + return () => { + if (ws.current) ws.current.close(); + }; + }, [nickname, fetchUnreadMessages]); + + return ( + <div className="max-w-6xl mx-auto p-6 font-sans"> + <h1 className="text-3xl font-bold mb-6">번개 채팅방 목록</h1> + <div className="flex justify-start mb-4"> + </div> + <div className="bg-white shadow-lg rounded-lg overflow-hidden"> + {rooms.length === 0 ? ( + <p className="text-gray-500 p-6 text-center">생성된 채팅방이 없습니다.</p> + ) : ( + <ul className="divide-y divide-gray-200"> + {rooms.map((chatRoom) => ( + <li + key={chatRoom.chatRoomId} + className="flex items-center justify-between w-full p-4 hover:bg-gray-50 cursor-pointer" + onClick={() => joinRoom(chatRoom.chatRoomId, chatRoom.chatRoomName)} // 클릭하면 상세 페이지로 이동 + > + <div className="flex items-center w-full"> + <div className="w-14 h-14 rounded-full bg-gray-300 overflow-hidden mr-4"> + <img + src="https://via.placeholder.com/50" + alt="프로필" + className="w-full h-full object-cover" + /> + </div> + <div className="flex-grow"> + <div className="font-semibold text-gray-800">{chatRoom.chatRoomName}</div> + <div className="text-sm text-gray-600"> + <strong>{chatRoom.lastMessage?.sender || "없음"}:</strong>{" "} + {chatRoom.lastMessage?.message || "메시지 없음"} + </div> + </div> + {joinedRooms.includes(chatRoom.chatRoomId) && ( + <div className="bg-red-500 text-white text-xs px-2 py-1 rounded-full"> + {unreadCounts[chatRoom.chatRoomId] || 0} + </div> + )} + </div> + </li> + ))} + </ul> + )} + </div> + </div> + ); +} + +export default ChattingList; \ No newline at end of file diff --git a/src/components/ChattingNoticeDetailModal.jsx b/src/components/ChattingNoticeDetailModal.jsx new file mode 100644 index 0000000000000000000000000000000000000000..05424ef51b9eabed2ee1ef3454514faba919c847 --- /dev/null +++ b/src/components/ChattingNoticeDetailModal.jsx @@ -0,0 +1,217 @@ +import React, { useState, useEffect } from "react"; +import styled from "styled-components"; +import Button from "./Button.jsx"; + +const ModalContainer = styled.div` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +`; + +const ModalContent = styled.div` + background: white; + width: 90%; + max-width: 600px; + padding: 20px; + border-radius: 10px; + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3); + overflow-y: auto; +`; + +const Header = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; + + .title { + font-size: 1.5rem; + font-weight: bold; + } +`; + +const NoticeHeader = styled.div` + display: flex; + align-items: center; + margin-bottom: 20px; + + .profile { + width: 50px; + height: 50px; + border-radius: 50%; + background-color: #f0f0f0; + margin-right: 15px; + } + + .info { + display: flex; + flex-direction: column; + + .author { + font-size: 1rem; + font-weight: bold; + color: #333; + } + + .timestamp { + font-size: 0.875rem; + color: #888; + } + } +`; + +const NoticeContent = styled.div` + margin-bottom: 20px; + font-size: 1rem; + line-height: 1.5; + white-space: pre-wrap; + + .section-title { + font-weight: bold; + margin-top: 10px; + margin-bottom: 5px; + } + + .section-content { + margin-bottom: 10px; + } +`; + +const NoticeListContainer = styled.ul` + list-style: none; + padding: 0; + margin: 0; + max-height: 400px; + overflow-y: auto; + + li { + display: flex; + flex-direction: column; + padding: 15px; + border-bottom: 1px solid #ddd; + cursor: pointer; + transition: background 0.3s ease; + + &:hover { + background: #f9f9f9; + } + + .message { + font-size: 1rem; + font-weight: bold; + color: #333; + margin-bottom: 5px; + } + + .meta { + font-size: 0.875rem; + color: #555; + display: flex; + justify-content: space-between; + + .timestamp { + color: #888; + } + + .sender { + color: #555; + font-weight: 500; + } + } + } +`; + +export default function ChattingNoticeDetailModal({ + initialNotice, + notices, + onClose, +}) { + const [currentView, setCurrentView] = useState("detail"); // 'detail' or 'list' + const [selectedNotice, setSelectedNotice] = useState(initialNotice); + const [sortedNotices, setSortedNotices] = useState([]); + + useEffect(() => { + // 최신 공지가 상단에 오도록 정렬 + const sorted = [...notices].sort( + (a, b) => new Date(b.timestamp) - new Date(a.timestamp) + ); + setSortedNotices(sorted); + }, [notices]); + + const handleSelectNotice = (notice) => { + setSelectedNotice(notice); + setCurrentView("detail"); + }; + + const formatTimestamp = (timestamp) => { + const date = new Date(timestamp); + const options = { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }; + return date.toLocaleDateString("ko-KR", options); + }; + + return ( + <ModalContainer> + <ModalContent> + {currentView === "detail" ? ( + <> + <Header> + <Button + size="sm" + theme="pink" + onClick={() => setCurrentView("list")} + > + ← 공지사항 목록 + </Button> + <Button onClick={onClose}>닫기</Button> + </Header> + <NoticeHeader> + <div className="profile" /> + <div className="info"> + <span className="author">{selectedNotice.sender}</span> + <span className="timestamp"> + {formatTimestamp(selectedNotice.timestamp)} + </span> + </div> + </NoticeHeader> + <NoticeContent> + <div className="section-content">{selectedNotice.message}</div> + </NoticeContent> + </> + ) : ( + <> + <Header> + <div className="title">공지사항 목록</div> + <Button onClick={onClose}>닫기</Button> + </Header> + <NoticeListContainer> + {sortedNotices.map((item, index) => ( + <li key={index} onClick={() => handleSelectNotice(item)}> + <div className="message">{item.message}</div> + <div className="meta"> + <span className="timestamp"> + {formatTimestamp(item.timestamp)} + </span> + <span className="sender">{item.sender}</span> + </div> + </li> + ))} + </NoticeListContainer> + </> + )} + </ModalContent> + </ModalContainer> + ); +} \ No newline at end of file diff --git a/src/components/ChattingNoticeListModal.jsx b/src/components/ChattingNoticeListModal.jsx new file mode 100644 index 0000000000000000000000000000000000000000..67e19804a642f8687a4f468d48a6bb33e0cf10aa --- /dev/null +++ b/src/components/ChattingNoticeListModal.jsx @@ -0,0 +1,72 @@ +import React from 'react'; +import styled from 'styled-components'; + +const ModalContainer = styled.div` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +`; + +const ModalContent = styled.div` + background: white; + width: 80%; + max-width: 600px; + padding: 20px; + border-radius: 10px; + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3); +`; + +const NoticeList = styled.ul` + list-style: none; + padding: 0; + margin: 0; + + li { + padding: 10px; + border-bottom: 1px solid #ddd; + cursor: pointer; + + &:hover { + background: #f0f0f0; + } + } +`; + +const CloseButton = styled.button` + margin-top: 20px; + padding: 10px 20px; + background: #007bff; + color: white; + border: none; + border-radius: 5px; + cursor: pointer; + + &:hover { + background: #0056b3; + } +`; + +export default function ChattingNoticeListModal({ notices, onClose, onNoticeClick }) { + return ( + <ModalContainer> + <ModalContent> + <h2>공지사항 목록</h2> + <NoticeList> + {notices.map((notice, index) => ( + <li key={index} onClick={() => onNoticeClick(notice)}> + {notice.message} (작성자: {notice.sender}) + </li> + ))} + </NoticeList> + <CloseButton onClick={onClose}>닫기</CloseButton> + </ModalContent> + </ModalContainer> + ); +} \ No newline at end of file diff --git a/src/fcm/AddServiceWorker.jsx b/src/fcm/AddServiceWorker.jsx new file mode 100644 index 0000000000000000000000000000000000000000..6a2159f29b4eaafb31d7ac7335757fb83d155a05 --- /dev/null +++ b/src/fcm/AddServiceWorker.jsx @@ -0,0 +1,26 @@ +import { firebaseApp } from "./firebase"; +import { + getMessaging, + onMessage, + onBackgroundMessage, +} from "firebase/messaging"; + +const messaging = getMessaging(firebaseApp); + +onMessage(messaging, (payload) => { + console.log("Message received. ", payload); +}); + +onBackgroundMessage(messaging, (payload) => { + console.log( + "[firebase-messaging-sw.js] Received background message ", + payload + ); + const notificationTitle = "Background Message Title"; + const notificationOptions = { + body: "Background Message body.", + icon: "/firebase-logo.png", + }; + + self.registration.showNotification(notificationTitle, notificationOptions); +}); diff --git a/src/fcm/GetFCMToken.jsx b/src/fcm/GetFCMToken.jsx new file mode 100644 index 0000000000000000000000000000000000000000..826c164693d44bca20d81ca71a087d67943e9281 --- /dev/null +++ b/src/fcm/GetFCMToken.jsx @@ -0,0 +1,35 @@ +import { firebaseApp } from "./firebase"; +import { getMessaging, getToken } from "firebase/messaging"; + +const GetFCMToken = async () => { + try { + const messagingPromise = new Promise((resolve, reject) => { + const messaging = getMessaging(firebaseApp); + if (messaging) { + resolve(messaging); + } else { + reject(new Error("Messaging object is not available")); + } + }); + + const messaging = await messagingPromise; + const currentToken = await getToken(messaging, { + vapidKey: process.env.REACT_APP_VAPID_KEY, + }); + + if (currentToken) { + localStorage.setItem("fcmToken", currentToken); + } else { + console.log( + "No registration token available. Request permission to generate one." + ); + } + } catch (error) { + console.error( + "An error occurred while sending the token to the server:", + error + ); + } +}; + +export default GetFCMToken; diff --git a/src/fcm/GetUserPermission.jsx b/src/fcm/GetUserPermission.jsx new file mode 100644 index 0000000000000000000000000000000000000000..621f1a7b4d218c69bb678ab436e340f60889696c --- /dev/null +++ b/src/fcm/GetUserPermission.jsx @@ -0,0 +1,85 @@ +import GetFCMToken from "./GetFCMToken"; +import { registerServiceWorker } from "../serviceWorkerRegistration"; +import Swal from "sweetalert2"; + +const Toast = Swal.mixin({ + toast: true, + position: "center-center", + showConfirmButton: false, + timer: 1000, + timerProgressBar: true, + didOpen: (toast) => { + toast.addEventListener("mouseenter", Swal.stopTimer); + toast.addEventListener("mouseleave", Swal.resumeTimer); + }, +}); + +const GetUserPermission = async (setIsLoading) => { + try { + //서비스워커 추가 + // await navigator.serviceWorker.register("firebase-messaging-sw.js"); + + // const registrations = await navigator.serviceWorker.getRegistrations(); + // if (registrations.length === 0) { + // setIsLoading(true); + // await registerServiceWorker(); + // setIsLoading(false); + // } + + if (!("Notification" in window)) { + alert( + "알림 서비스를 원활하게 사용하시려면 바탕화면에 바로가기 추가 후, 홈페이지에 종모양아이콘 클릭하여 알림 허용을 해주세요." + ); + return; + } + + console.log("Checking notification permission..."); + + const permission = await Notification.requestPermission(); + if (permission === "granted") { + try { + console.log("Notification permission granted. Ready to send token..."); + setIsLoading(true); + await GetFCMToken(); + setIsLoading(false); + let isFCMToken = localStorage.getItem("fcmToken"); + if (!isFCMToken) { + setIsLoading(true); + await GetFCMToken(); + setIsLoading(false); + Toast.fire({ + icon: "error", + title: `알림 토큰 저장 실패`, + }); + } else { + console.log("token setting complete"); + } + } catch { + Toast.fire({ + icon: "error", + title: `알림 토큰 요청 실패`, + }); + } + } else if (permission === "denied") { + // alert("알림권한이 허용되어 있지않습니다. 권한을 허용해 주십시오."); + console.log( + "Notification permission not granted. Requesting permission..." + ); + } else { + // Toast.fire({ + // icon: "warning", + // title: `알림 설정 안함`, + // text: "알림 설정 요청을 원하시면 종아이콘을 클릭해주세요.", + // }); + } + } catch (error) { + Toast.fire({ + icon: "error", + title: `알림 설정 요청 실패`, + }); + console.error("Failed to check or request notification permission:", error); + setIsLoading(false); + } +}; + +export default GetUserPermission; diff --git a/src/fcm/firebase.js b/src/fcm/firebase.js new file mode 100644 index 0000000000000000000000000000000000000000..10fd4e0ed2041d46bf2d18b9df3d4d8b92ae4a3b --- /dev/null +++ b/src/fcm/firebase.js @@ -0,0 +1,14 @@ +import { initializeApp } from "firebase/app"; + +const firebaseConfig = { + apiKey: process.env.REACT_APP_FIREBASE_API_KEY, + authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN, + projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID, + storageBucket: process.env.REACT_APP_FIREBASE_STORAGE_BUCKET, + messagingSenderId: process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID, + appId: process.env.REACT_APP_FIREBASE_APP_ID, + measurementId: process.env.REACT_APP_FIREBASE_MEASUREMENT_ID, +}; + +const firebaseApp = initializeApp(firebaseConfig); +export { firebaseApp }; diff --git a/src/pages/Chatting/ChattingDetail.jsx b/src/pages/Chatting/ChattingDetail.jsx deleted file mode 100644 index 2b1bf0202cad968f338b8648c2076978b2bc5b69..0000000000000000000000000000000000000000 --- a/src/pages/Chatting/ChattingDetail.jsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from "react"; - -const ChattingDetail = () => { - return <></>; -}; - -export default ChattingDetail; diff --git a/src/pages/Chatting/ChattingDetailPage.jsx b/src/pages/Chatting/ChattingDetailPage.jsx new file mode 100644 index 0000000000000000000000000000000000000000..2bcc7767c338deb64a9ba85e2105a48f93d38ba9 --- /dev/null +++ b/src/pages/Chatting/ChattingDetailPage.jsx @@ -0,0 +1,10 @@ +import React from "react"; +import ChattingDetail from "../../components/ChattingDetail"; + +export default function ChattingDetailPage() { + return ( + <div className="flex flex-col items-center justify-center bg-white min-h-screen overflow-hidden"> + <ChattingDetail /> + </div> + ); +} \ No newline at end of file diff --git a/src/pages/Chatting/ChattingListPage.jsx b/src/pages/Chatting/ChattingListPage.jsx index e77427b1e3fe8b5c192d03dfb5abb063468d3562..67a44e69630c9eb7a35cc0fdaa5f10978c665fa2 100644 --- a/src/pages/Chatting/ChattingListPage.jsx +++ b/src/pages/Chatting/ChattingListPage.jsx @@ -1,7 +1,10 @@ import React from "react"; +import ChattingList from "../../components/ChattingList"; -const ChattingList = () => { - return <></>; -}; - -export default ChattingList; +export default function ChattingListPage() { + return ( + <div className="flex flex-col items-center justify-center bg-white min-h-screen overflow-hidden"> + <ChattingList /> + </div> + ); +} \ No newline at end of file