diff --git a/.env.development b/.env.development index 93b86c7..bc487cd 100644 --- a/.env.development +++ b/.env.development @@ -1 +1,11 @@ -VITE_API_URL = https://api.splanet.co.kr \ No newline at end of file +VITE_API_URL = https://api.splanet.co.kr +VITE_LOGIN_URL = https://api.splanet.co.kr/oauth2/authorization/kakao + +VITE_FIREBASE_API_KEY="AIzaSyAInigygScRLDilnWcnArBN8LMbQRpDZVk" +VITE_FIREBASE_AUTH_DOMAIN="splanet-cef14.firebaseapp.com" +VITE_FIREBASE_PROJECT_ID="splanet-cef14" +VITE_FIREBASE_STORAGE_BUCKET="splanet-cef14.appspot.com" +VITE_FIREBASE_MESSAGING_SENDER_ID="995362943401" +VITE_FIREBASE_APP_ID="1:995362943401:web:cef434d0e3f51d31a4d4b8" +VITE_FIREBASE_MEASUREMENT_ID="G-LZJKRYBSJV" +VITE_FIREBASE_VAPID_KEY="BHGMkWgFVGKfNFkDm81jQF0GdtehYhZMEeHeQ3kx4ViRmLaoqH2YFjxNdl7guYA4NKEFpxYlHhoXeLMyJtyJrmI" \ No newline at end of file diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..93b86c7 --- /dev/null +++ b/.env.production @@ -0,0 +1 @@ +VITE_API_URL = https://api.splanet.co.kr \ No newline at end of file diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 1978302..6582a95 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -13,7 +13,7 @@ module.exports = { parserOptions: { project: "./tsconfig.json", }, - ignorePatterns: ["dist", ".eslintrc.cjs"], + ignorePatterns: ["dist", ".eslintrc.cjs", "firebase-messaging-sw.js"], parser: "@typescript-eslint/parser", plugins: [ "react-refresh", @@ -36,7 +36,7 @@ module.exports = { "react/jsx-props-no-spreading": "off", "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/naming-convention": "off", - '@typescript-eslint/no-unused-vars': 'off', + "@typescript-eslint/no-unused-vars": "off", "jsx-a11y/click-events-have-key-events": "off", "no-underscore-dangle": "off", "no-alert": "off", diff --git a/.gitignore b/.gitignore index bc6d306..0b03a2d 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,6 @@ dist-ssr *.sln *.sw? -*storybook.log \ No newline at end of file +*storybook.log + +.env .development \ No newline at end of file diff --git a/firebase-messaging-sw.js b/firebase-messaging-sw.js new file mode 100644 index 0000000..f7edbb9 --- /dev/null +++ b/firebase-messaging-sw.js @@ -0,0 +1,27 @@ +importScripts( + "https://www.gstatic.com/firebasejs/9.6.1/firebase-messaging-compat.js", +); + +firebase.initializeApp({ + apiKey: "AIzaSyAInigygScRLDilnWcnArBN8LMbQRpDZVk", + authDomain: "splanet-cef14.firebaseapp.com", + projectId: "splanet-cef14", + storageBucket: "splanet-cef14.appspot.com", + messagingSenderId: "995362943401", + appId: "1:995362943401:web:cef434d0e3f51d31a4d4b8", + measurementId: "G-LZJKRYBSJV", +}); + +const messaging = firebase.messaging(); + +// 알림 수신 대기 +messaging.onBackgroundMessage((payload) => { + console.log("Received background message ", payload); + // Customize notification + const notificationTitle = payload.notification.title; + const notificationOptions = { + body: payload.notification.body, + }; + + self.registration.showNotification(notificationTitle, notificationOptions); +}); diff --git a/index.html b/index.html index 5cfbd76..68ceccf 100644 --- a/index.html +++ b/index.html @@ -8,6 +8,9 @@ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet" /> + + +
diff --git a/package-lock.json b/package-lock.json index 28fadf7..6271adc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", + "@fullcalendar/common": "^5.11.5", "@fullcalendar/core": "^6.1.15", "@fullcalendar/daygrid": "^6.1.15", "@fullcalendar/interaction": "^6.1.15", @@ -19,12 +20,19 @@ "@mui/material": "^6.1.1", "@tanstack/react-query": "^5.56.2", "axios": "^1.7.7", + "date-fns": "^4.1.0", + "events": "^3.3.0", + "firebase": "^11.0.1", "framer-motion": "^11.9.0", "js-cookie": "^3.0.5", + "lucide-react": "^0.456.0", "moment": "^2.30.1", "normalize.css": "^8.0.1", + "postcss": "^8.4.47", + "postcss-import": "^16.1.0", "react": "^18.2.0", "react-cookie": "^7.2.2", + "react-datepicker": "^7.5.0", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "^18.2.0", @@ -1077,11 +1085,677 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@firebase/analytics": { + "version": "0.10.9", + "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.9.tgz", + "integrity": "sha512-FrvW6u6xDBKXUGYUy1WIUh0J9tvbppMsk90mig0JhHST8iLveKu/dIBVeVE/ZYZhmXy4fkI7SPSWvD1V0O4tXw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.10", + "@firebase/installations": "0.6.10", + "@firebase/logger": "0.4.3", + "@firebase/util": "1.10.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/analytics-compat": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.15.tgz", + "integrity": "sha512-C5to422Sr8FkL0MPwXcIecbMnF4o2Ll7MtoWvIm4Q/LPJvvM+tWa1DiU+LzsCdsd1/CYE9EIW9Ma3ko9XnAAYw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/analytics": "0.10.9", + "@firebase/analytics-types": "0.8.2", + "@firebase/component": "0.6.10", + "@firebase/util": "1.10.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/analytics-types": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/@firebase/analytics-types/-/analytics-types-0.8.2.tgz", + "integrity": "sha512-EnzNNLh+9/sJsimsA/FGqzakmrAUKLeJvjRHlg8df1f97NLUlFidk9600y0ZgWOp3CAxn6Hjtk+08tixlUOWyw==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app": { + "version": "0.10.15", + "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.10.15.tgz", + "integrity": "sha512-he6qlG3pmwL+LHdG/BrSMBQeJzzutciq4fpXN3lGa1uSwYSijJ24VtakS/bP2X9SiDf8jGywJ4u+OgXAenJsNg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.10", + "@firebase/logger": "0.4.3", + "@firebase/util": "1.10.1", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/app-check": { + "version": "0.8.9", + "resolved": "https://registry.npmjs.org/@firebase/app-check/-/app-check-0.8.9.tgz", + "integrity": "sha512-YzVn1mMLzD2JboMPVVO0Pe20YOgWzrF+aXoAmmd0v3xec051n83YpxSUZbacL69uYvk0dHrEsbea44QtQ5WPDA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.10", + "@firebase/logger": "0.4.3", + "@firebase/util": "1.10.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/app-check-compat": { + "version": "0.3.16", + "resolved": "https://registry.npmjs.org/@firebase/app-check-compat/-/app-check-compat-0.3.16.tgz", + "integrity": "sha512-AxIGzLRXrTFNL+H6V+4BO0w/gERloROfRbWI/FoJUnQd0qPZIzyfdHZBbThFzFGLfDt/mVs2kdjYFx/l9I8NhQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check": "0.8.9", + "@firebase/app-check-types": "0.5.2", + "@firebase/component": "0.6.10", + "@firebase/logger": "0.4.3", + "@firebase/util": "1.10.1", + "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.2", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.2.tgz", + "integrity": "sha512-LMs47Vinv2HBMZi49C09dJxp0QT5LwDzFaVGf/+ITHe3BlIhUiLNttkATSXplc89A2lAaeTqjgqVkiRfUGyQiQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-check-types": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@firebase/app-check-types/-/app-check-types-0.5.2.tgz", + "integrity": "sha512-FSOEzTzL5bLUbD2co3Zut46iyPWML6xc4x+78TeaXMSuJap5QObfb+rVvZJtla3asN4RwU7elaQaduP+HFizDA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-compat": { + "version": "0.2.45", + "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.2.45.tgz", + "integrity": "sha512-5rYbXq1ndtMTg+07oH4WrkYuP+NZq61uzVwW1hlmybp/gr4cXq2SfaP9fc6/9IzTKmu3dh3H0fjj++HG7Z7o/w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app": "0.10.15", + "@firebase/component": "0.6.10", + "@firebase/logger": "0.4.3", + "@firebase/util": "1.10.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/app-types": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.2.tgz", + "integrity": "sha512-oMEZ1TDlBz479lmABwWsWjzHwheQKiAgnuKxE0pz0IXCVx7/rtlkx1fQ6GfgK24WCrxDKMplZrT50Kh04iMbXQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.8.0.tgz", + "integrity": "sha512-/O7UDWE5S5ux456fzNHSLx/0YN/Kykw/WyAzgDQ6wvkddZhSEmPX19EzxgsFldzhuFjsl5uOZTz8kzlosCiJjg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.10", + "@firebase/logger": "0.4.3", + "@firebase/util": "1.10.1", + "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.15", + "resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.5.15.tgz", + "integrity": "sha512-jz6k1ridPiecKI8CBRiqCM6IMOhwYp2MD+YvoxnMiK8nQLSTm57GvHETlPNX3WlbyQnCjMCOvrAhe27whyxAEg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth": "1.8.0", + "@firebase/auth-types": "0.12.2", + "@firebase/component": "0.6.10", + "@firebase/util": "1.10.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.3.tgz", + "integrity": "sha512-Fc9wuJGgxoxQeavybiuwgyi+0rssr76b+nHpj+eGhXFYAdudMWyfBHvFL/I5fEHniUM/UQdFzi9VXJK2iZF7FQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-types": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@firebase/auth-types/-/auth-types-0.12.2.tgz", + "integrity": "sha512-qsEBaRMoGvHO10unlDJhaKSuPn4pyoTtlQuP1ghZfzB6rNQPuhp/N/DcFZxm9i4v0SogjCbf9reWupwIvfmH6w==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/component": { + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.6.10.tgz", + "integrity": "sha512-OsNbEKyz9iLZSmMUhsl6+kCADzte00iisJIRUspnUqvDCX+RSGZOBIqekukv/jN177ovjApBQNFaxSYIDc/SyQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.10.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/data-connect": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@firebase/data-connect/-/data-connect-0.1.1.tgz", + "integrity": "sha512-RBJ7XE/a3oXFv31Jlw8cbMRdsxQoI8F3L7xm4n93ab+bIr1NQUiYGgW9L7TTw7obdNev91ZnW0xfqJtXcPA5yA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth-interop-types": "0.2.3", + "@firebase/component": "0.6.10", + "@firebase/logger": "0.4.3", + "@firebase/util": "1.10.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/database": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.0.9.tgz", + "integrity": "sha512-EkiPSKSu2TJJGtOjyISASf3UFpFJDil1lMbfqnxilfbmIsilvC8DzgjuLoYD+eOitcug4wtU9Fh1tt2vgBhskA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.2", + "@firebase/auth-interop-types": "0.2.3", + "@firebase/component": "0.6.10", + "@firebase/logger": "0.4.3", + "@firebase/util": "1.10.1", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.0.0.tgz", + "integrity": "sha512-2xlODKWwf/vNAxCmou0GFhymx2pqZKkhXMN9B5aiTjZ6+81sOxGim53ELY2lj+qKG2IvgiCYFc4X+ZJA2Ad5vg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.10", + "@firebase/database": "1.0.9", + "@firebase/database-types": "1.0.6", + "@firebase/logger": "0.4.3", + "@firebase/util": "1.10.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.6.tgz", + "integrity": "sha512-sMI7IynSZBsyGbUugc8PKE1jwKbnvaieAz/RxuM57PZQNCi6Rteiviwcw/jqZOX6igqYJwXWZ3UzKOZo2nUDRA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.2", + "@firebase/util": "1.10.1" + } + }, + "node_modules/@firebase/firestore": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.7.4.tgz", + "integrity": "sha512-K2nq4w+NF8J1waGawY5OHLawP/Aw5CYxyDstVv1NZemGPcM3U+LZ9EPaXr1PatYIrPA7fS4DxZoWcbB0aGJ8Zg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.10", + "@firebase/logger": "0.4.3", + "@firebase/util": "1.10.1", + "@firebase/webchannel-wrapper": "1.0.2", + "@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.39", + "resolved": "https://registry.npmjs.org/@firebase/firestore-compat/-/firestore-compat-0.3.39.tgz", + "integrity": "sha512-CsK8g34jNeHx95LISDRTcArJLonW+zJCqHI1Ez9WNiLAK2X8FeQ4UiD+RwOwxAIR+t2a6xED/5Fe6ZIqx7MuoQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.10", + "@firebase/firestore": "4.7.4", + "@firebase/firestore-types": "3.0.2", + "@firebase/util": "1.10.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/firestore-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@firebase/firestore-types/-/firestore-types-3.0.2.tgz", + "integrity": "sha512-wp1A+t5rI2Qc/2q7r2ZpjUXkRVPtGMd6zCLsiWurjsQpqPgFin3AhNibKcIzoF2rnToNa/XYtyWXuifjOOwDgg==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/functions": { + "version": "0.11.9", + "resolved": "https://registry.npmjs.org/@firebase/functions/-/functions-0.11.9.tgz", + "integrity": "sha512-dhO5IUfQRCsrc20YD20nSOX+QCT+cH6N86HlZOLz2XgyEFgzOdBQnUot4EabBJQRkMBI7fZWUrbYfRcnov53ug==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.2", + "@firebase/auth-interop-types": "0.2.3", + "@firebase/component": "0.6.10", + "@firebase/messaging-interop-types": "0.2.2", + "@firebase/util": "1.10.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/functions-compat": { + "version": "0.3.15", + "resolved": "https://registry.npmjs.org/@firebase/functions-compat/-/functions-compat-0.3.15.tgz", + "integrity": "sha512-eiHpc6Sd9Y/SNhBsGi944SapiFbfTPKsiSUQ74QxNSs0yoxvABeIRolVMFk4TokP57NGmstGYpYte02XGNPcYw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.10", + "@firebase/functions": "0.11.9", + "@firebase/functions-types": "0.6.2", + "@firebase/util": "1.10.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/functions-types": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@firebase/functions-types/-/functions-types-0.6.2.tgz", + "integrity": "sha512-0KiJ9lZ28nS2iJJvimpY4nNccV21rkQyor5Iheu/nq8aKXJqtJdeSlZDspjPSBBiHRzo7/GMUttegnsEITqR+w==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/installations": { + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.10.tgz", + "integrity": "sha512-TuGSOMqkFrllxa0X/8VZIqBCRH4POndU/iWKWkRmkh12+/xKSpdp+y/kWaVbsySrelltan6LeYlcYPmLibWbwg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.10", + "@firebase/util": "1.10.1", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/installations-compat": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@firebase/installations-compat/-/installations-compat-0.2.10.tgz", + "integrity": "sha512-YTonkcVz3AK7RF8xFhvs5CwDuJ0xbzzCJIwXoV14gnzdYbMgy6vWlUUbzkvbtEDXzPRHB0n7aGZl56oy9dLOFw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.10", + "@firebase/installations": "0.6.10", + "@firebase/installations-types": "0.5.2", + "@firebase/util": "1.10.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/installations-types": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@firebase/installations-types/-/installations-types-0.5.2.tgz", + "integrity": "sha512-que84TqGRZJpJKHBlF2pkvc1YcXrtEDOVGiDjovP/a3s6W4nlbohGXEsBJo0JCeeg/UG9A+DEZVDUV9GpklUzA==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/logger": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.4.3.tgz", + "integrity": "sha512-Th42bWJg18EF5bJwhRosn2M/eYxmbWCwXZr4hHX7ltO0SE3QLrpgiMKeRBR/NW7vJke7i0n3i8esbCW2s93qBw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/messaging": { + "version": "0.12.13", + "resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.12.13.tgz", + "integrity": "sha512-YLa8PWl+BgiOVR5WOyzl21fVJFJeBRfniNuN25d9DBrQzppSAahuN6yS+vt1OIjvZNPN4pZ/lcRLYupbGu4W0w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.10", + "@firebase/installations": "0.6.10", + "@firebase/messaging-interop-types": "0.2.2", + "@firebase/util": "1.10.1", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/messaging-compat": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/@firebase/messaging-compat/-/messaging-compat-0.2.13.tgz", + "integrity": "sha512-9ootPClS6m2c2KIzo7AqSHaWzAw28zWcjQPjVv7WeQDu6wjufpbOg+7tuVzb+gqpF9Issa3lDoYOwlO0ZudO3g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.10", + "@firebase/messaging": "0.12.13", + "@firebase/util": "1.10.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/messaging-interop-types": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.2.tgz", + "integrity": "sha512-l68HXbuD2PPzDUOFb3aG+nZj5KA3INcPwlocwLZOzPp9rFM9yeuI9YLl6DQfguTX5eAGxO0doTR+rDLDvQb5tA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/performance": { + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/@firebase/performance/-/performance-0.6.10.tgz", + "integrity": "sha512-x/mNYKGxq7A+QV0EiEZeD2S+E+kw+UcZ8FXuE7qDJyGGt/0Wd+bIIL7RakG/VrFt7/UYc//nKygDc7/Ig7sOmQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.10", + "@firebase/installations": "0.6.10", + "@firebase/logger": "0.4.3", + "@firebase/util": "1.10.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/performance-compat": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@firebase/performance-compat/-/performance-compat-0.2.10.tgz", + "integrity": "sha512-0h1qYkF6I79DSSpHfTQFvb91fo8shmmwiPzWFYAPdPK02bSWpKwVssNYlZX2iUnumxerDMbl7dWN+Im/W3bnXA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.10", + "@firebase/logger": "0.4.3", + "@firebase/performance": "0.6.10", + "@firebase/performance-types": "0.2.2", + "@firebase/util": "1.10.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/performance-types": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@firebase/performance-types/-/performance-types-0.2.2.tgz", + "integrity": "sha512-gVq0/lAClVH5STrIdKnHnCo2UcPLjJlDUoEB/tB4KM+hAeHUxWKnpT0nemUPvxZ5nbdY/pybeyMe8Cs29gEcHA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/remote-config": { + "version": "0.4.10", + "resolved": "https://registry.npmjs.org/@firebase/remote-config/-/remote-config-0.4.10.tgz", + "integrity": "sha512-jTRjy3TdqzVna19m5a1HEHE5BG4Z3BQTxBgvQRTmMKlHacx4QS0CToAas7R9M9UkxpgFcVuAE7FpWIOWQGCEWw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.10", + "@firebase/installations": "0.6.10", + "@firebase/logger": "0.4.3", + "@firebase/util": "1.10.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/remote-config-compat": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-compat/-/remote-config-compat-0.2.10.tgz", + "integrity": "sha512-fIi5OB2zk0zpChMV/tTd0oEZcZI8TlwQDlLlcrDpMOV5l5dqd0JNlWKh6Fwmh4izmytk+rZIAIpnak/NjGVesQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.10", + "@firebase/logger": "0.4.3", + "@firebase/remote-config": "0.4.10", + "@firebase/remote-config-types": "0.3.2", + "@firebase/util": "1.10.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/remote-config-types": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-types/-/remote-config-types-0.3.2.tgz", + "integrity": "sha512-0BC4+Ud7y2aPTyhXJTMTFfrGGLqdYXrUB9sJVAB8NiqJswDTc4/2qrE/yfUbnQJhbSi6ZaTTBKyG3n1nplssaA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/storage": { + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.13.3.tgz", + "integrity": "sha512-B5HiJ7isYKaT4dOEV43f2ySdhQxzq+SQEm7lqXebJ8AYCsebdHrgGzrPR0LR962xGjPzJHFKx63gA8Be/P2MCw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.10", + "@firebase/util": "1.10.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/storage-compat": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@firebase/storage-compat/-/storage-compat-0.3.13.tgz", + "integrity": "sha512-15kje7JALswRCBKsCSvKg5FbqUYykaIMqMbZRD7I6uVRWwdyTvez5MBQfMhBia2JcEmPiDpXhJTXH4PAWFiA8g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.10", + "@firebase/storage": "0.13.3", + "@firebase/storage-types": "0.8.2", + "@firebase/util": "1.10.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/storage-types": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/@firebase/storage-types/-/storage-types-0.8.2.tgz", + "integrity": "sha512-0vWu99rdey0g53lA7IShoA2Lol1jfnPovzLDUBuon65K7uKG9G+L5uO05brD9pMw+l4HRFw23ah3GwTGpEav6g==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/util": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.10.1.tgz", + "integrity": "sha512-AIhFnCCjM8FmCqSNlNPTuOk3+gpHC1RkeNUBLtPbcqGYpN5MxI5q7Yby+rxycweOZOCboDzfIj8WyaY4tpQG/g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/vertexai": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@firebase/vertexai/-/vertexai-1.0.0.tgz", + "integrity": "sha512-48N3Lp/9GgiCCRfrSdHS+Y1IiMdYXvnHFO/f+HL1PgUtBq7WQ/fWmYOX3mzAN36zvytq13nb68ImF+GALopp+Q==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.2", + "@firebase/component": "0.6.10", + "@firebase/logger": "0.4.3", + "@firebase/util": "1.10.1", + "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.2", + "resolved": "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.2.tgz", + "integrity": "sha512-3F4iA2E+NtdMbOU0XC1cHE8q6MqpGIKRj62oGOF38S6AAx5VHR9cXmoDUSj7ejvTAT7m6jxuEeQkHeq0F+mU2w==", + "license": "Apache-2.0" + }, + "node_modules/@floating-ui/core": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz", + "integrity": "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.12.tgz", + "integrity": "sha512-NP83c0HjokcGVEMeoStg317VD9W7eDlGK7457dMBANbKA6GJZdc7rjujdgqzTaz93jkGgc5P/jeWbaCHnMNc+w==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.26.27", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.27.tgz", + "integrity": "sha512-jLP72x0Kr2CgY6eTYi/ra3VA9LOkTo4C+DUTrbFgFOExKy3omYVmwMjNKqxAHdsnyLS96BIDLcO2SlnsNf8KUQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.8", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", + "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", + "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==", + "license": "MIT" + }, + "node_modules/@fullcalendar/common": { + "version": "5.11.5", + "resolved": "https://registry.npmjs.org/@fullcalendar/common/-/common-5.11.5.tgz", + "integrity": "sha512-3iAYiUbHXhjSVXnYWz27Od2cslztUPsOwiwKlfGvQxBixv2Kl6a8IPwaijKFYJHXdwYmfPoEgK7rvqAGVoIYwA==", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/@fullcalendar/core": { "version": "6.1.15", "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.15.tgz", "integrity": "sha512-BuX7o6ALpLb84cMw1FCB9/cSgF4JbVO894cjJZ6kP74jzbUZNjtwffwRdA+Id8rrLjT30d/7TrkW90k4zbXB5Q==", - "license": "MIT", "dependencies": { "preact": "~10.12.1" } @@ -1090,7 +1764,6 @@ "version": "6.1.15", "resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.15.tgz", "integrity": "sha512-j8tL0HhfiVsdtOCLfzK2J0RtSkiad3BYYemwQKq512cx6btz6ZZ2RNc/hVnIxluuWFyvx5sXZwoeTJsFSFTEFA==", - "license": "MIT", "peerDependencies": { "@fullcalendar/core": "~6.1.15" } @@ -1099,7 +1772,6 @@ "version": "6.1.15", "resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.15.tgz", "integrity": "sha512-DOTSkofizM7QItjgu7W68TvKKvN9PSEEvDJceyMbQDvlXHa7pm/WAVtAc6xSDZ9xmB1QramYoWGLHkCYbTW1rQ==", - "license": "MIT", "peerDependencies": { "@fullcalendar/core": "~6.1.15" } @@ -1108,7 +1780,6 @@ "version": "6.1.15", "resolved": "https://registry.npmjs.org/@fullcalendar/react/-/react-6.1.15.tgz", "integrity": "sha512-L0b9hybS2J4e7lq6G2CD4nqriyLEqOH1tE8iI6JQjAMTVh5JicOo5Mqw+fhU5bJ7hLfMw2K3fksxX3Ul1ssw5w==", - "license": "MIT", "peerDependencies": { "@fullcalendar/core": "~6.1.15", "react": "^16.7.0 || ^17 || ^18 || ^19", @@ -1119,7 +1790,6 @@ "version": "6.1.15", "resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.15.tgz", "integrity": "sha512-61ORr3A148RtxQ2FNG7JKvacyA/TEVZ7z6I+3E9Oeu3dqTf6M928bFcpehRTIK6zIA6Yifs7BeWHgOE9dFnpbw==", - "license": "MIT", "dependencies": { "@fullcalendar/daygrid": "~6.1.15" }, @@ -1127,6 +1797,37 @@ "@fullcalendar/core": "~6.1.15" } }, + "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/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -1668,6 +2369,70 @@ "url": "https://opencollective.com/popperjs" } }, + "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/@react-dnd/asap": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz", @@ -2998,7 +3763,6 @@ "version": "22.5.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.5.tgz", "integrity": "sha512-Xjs4y5UPO/CLdzpgR6GirZJx36yScjh73+2NlLlkFRSoQN8B0DpfXPdZGnvVmLRLOsqDpOfTNv7D9trgGhmOIA==", - "devOptional": true, "dependencies": { "undici-types": "~6.19.2" } @@ -3509,7 +4273,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "engines": { "node": ">=8" } @@ -4049,7 +4812,6 @@ "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==", - "dev": true, "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", @@ -4063,7 +4825,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -4078,7 +4839,6 @@ "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, "dependencies": { "color-name": "~1.1.4" }, @@ -4089,14 +4849,12 @@ "node_modules/cliui/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 + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/cliui/node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -4298,6 +5056,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", @@ -4497,8 +5265,7 @@ "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "node_modules/encodeurl": { "version": "2.0.0", @@ -4755,7 +5522,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "engines": { "node": ">=6" } @@ -5674,6 +6440,14 @@ "node": ">= 0.6" } }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/express": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", @@ -5791,6 +6565,18 @@ "reusify": "^1.0.4" } }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -5895,6 +6681,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/firebase": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/firebase/-/firebase-11.0.1.tgz", + "integrity": "sha512-qsFb8dMcQINEDhJteG7RP+GqwgSRvfyiexQqHd5JToDdm87i9I2rGC4XQsGawKGxzKwZ/ISdgwNWxXAFYdCC6A==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/analytics": "0.10.9", + "@firebase/analytics-compat": "0.2.15", + "@firebase/app": "0.10.15", + "@firebase/app-check": "0.8.9", + "@firebase/app-check-compat": "0.3.16", + "@firebase/app-compat": "0.2.45", + "@firebase/app-types": "0.9.2", + "@firebase/auth": "1.8.0", + "@firebase/auth-compat": "0.5.15", + "@firebase/data-connect": "0.1.1", + "@firebase/database": "1.0.9", + "@firebase/database-compat": "2.0.0", + "@firebase/firestore": "4.7.4", + "@firebase/firestore-compat": "0.3.39", + "@firebase/functions": "0.11.9", + "@firebase/functions-compat": "0.3.15", + "@firebase/installations": "0.6.10", + "@firebase/installations-compat": "0.2.10", + "@firebase/messaging": "0.12.13", + "@firebase/messaging-compat": "0.2.13", + "@firebase/performance": "0.6.10", + "@firebase/performance-compat": "0.2.10", + "@firebase/remote-config": "0.4.10", + "@firebase/remote-config-compat": "0.2.10", + "@firebase/storage": "0.13.3", + "@firebase/storage-compat": "0.3.13", + "@firebase/util": "1.10.1", + "@firebase/vertexai": "1.0.0" + } + }, "node_modules/flat-cache": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", @@ -6080,7 +6902,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "engines": { "node": "6.* || 8.* || >= 10.*" } @@ -6447,6 +7268,12 @@ "node": ">= 0.8" } }, + "node_modules/http-parser-js": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.8.tgz", + "integrity": "sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==", + "license": "MIT" + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -6459,6 +7286,12 @@ "node": ">=0.10.0" } }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "license": "ISC" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -6737,7 +7570,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "engines": { "node": ">=8" } @@ -7158,12 +7990,24 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, + "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.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 }, + "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", @@ -7193,6 +8037,14 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.456.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.456.0.tgz", + "integrity": "sha512-DIIGJqTT5X05sbAsQ+OhA8OtJYyD4NsEMCA/HQW/Y6ToPQ7gwbtujIoeAaup4HpHzV35SQOarKAWH8LYglB6eA==", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -7534,7 +8386,6 @@ "version": "3.3.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", - "dev": true, "funding": [ { "type": "github", @@ -7893,6 +8744,14 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", @@ -7982,7 +8841,6 @@ "version": "8.4.47", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -8006,6 +8864,27 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-import": { + "version": "16.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-16.1.0.tgz", + "integrity": "sha512-7hsAZ4xGXl4MW+OKEWCnF6T5jqBw80/EE9aXg1r2yyn1RsVEU8EtKXbijEODa+rg7iih4bKf7vlvTGYR4CnPNg==", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + }, "node_modules/preact": { "version": "10.12.1", "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz", @@ -8102,6 +8981,30 @@ "react-is": "^16.13.1" } }, + "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", @@ -8250,6 +9153,32 @@ "react": ">= 16.3.0" } }, + "node_modules/react-datepicker": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-7.5.0.tgz", + "integrity": "sha512-6MzeamV8cWSOcduwePHfGqY40acuGlS1cG//ePHT6bVbLxWyqngaStenfH03n1wbzOibFggF66kWaBTb1SbTtQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.26.23", + "clsx": "^2.1.1", + "date-fns": "^3.6.0", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17 || ^18", + "react-dom": "^16.9.0 || ^17 || ^18" + } + }, + "node_modules/react-datepicker/node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/react-dnd": { "version": "16.0.1", "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz", @@ -8428,6 +9357,14 @@ "react-dom": ">=16.6.0" } }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dependencies": { + "pify": "^2.3.0" + } + }, "node_modules/recast": { "version": "0.23.9", "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.9.tgz", @@ -8570,7 +9507,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -8720,7 +9656,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -8952,7 +9887,6 @@ "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, "engines": { "node": ">=0.10.0" } @@ -9016,7 +9950,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -9125,7 +10058,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -9212,6 +10144,12 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", + "license": "MIT" + }, "node_modules/telejson": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/telejson/-/telejson-7.2.0.tgz", @@ -9512,8 +10450,7 @@ "node_modules/undici-types": { "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "devOptional": true + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" }, "node_modules/unist-util-is": { "version": "6.0.0", @@ -9777,6 +10714,29 @@ "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "dev": true }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -9957,7 +10917,6 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, "engines": { "node": ">=10" } @@ -9980,7 +10939,6 @@ "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", @@ -9998,7 +10956,6 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, "engines": { "node": ">=12" } diff --git a/package.json b/package.json index e5c6bfe..fb5feeb 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc && vite build", + "build": "vite build", "lint": "eslint \"./src/**/*.{js,jsx,ts,tsx}\" --report-unused-disable-directives --max-warnings 0", "lint:fix": "eslint './src/**/*.{js,jsx,ts,tsx}' --fix", "format": "prettier --write './src/**/*.{js,jsx,ts,tsx,json,css,md}'", @@ -17,6 +17,7 @@ "dependencies": { "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", + "@fullcalendar/common": "^5.11.5", "@fullcalendar/core": "^6.1.15", "@fullcalendar/daygrid": "^6.1.15", "@fullcalendar/interaction": "^6.1.15", @@ -26,12 +27,19 @@ "@mui/material": "^6.1.1", "@tanstack/react-query": "^5.56.2", "axios": "^1.7.7", + "events": "^3.3.0", + "date-fns": "^4.1.0", + "firebase": "^11.0.1", "framer-motion": "^11.9.0", "js-cookie": "^3.0.5", + "lucide-react": "^0.456.0", "moment": "^2.30.1", "normalize.css": "^8.0.1", + "postcss": "^8.4.47", + "postcss-import": "^16.1.0", "react": "^18.2.0", "react-cookie": "^7.2.2", + "react-datepicker": "^7.5.0", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "^18.2.0", diff --git a/public/firebase-messaging-sw.js b/public/firebase-messaging-sw.js new file mode 100644 index 0000000..6bdb526 --- /dev/null +++ b/public/firebase-messaging-sw.js @@ -0,0 +1,31 @@ +importScripts( + "https://www.gstatic.com/firebasejs/9.0.0/firebase-app-compat.js", +); +importScripts( + "https://www.gstatic.com/firebasejs/9.0.0/firebase-messaging-compat.js", +); + +// 환경 변수에서 Firebase 설정값 가져오기 +firebase.initializeApp({ + apiKey: "AIzaSyAInigygScRLDilnWcnArBN8LMbQRpDZVk", + authDomain: "splanet-cef14.firebaseapp.com", + projectId: "splanet-cef14", + storageBucket: "splanet-cef14.appspot.com", + messagingSenderId: "995362943401", + appId: "1:995362943401:web:cef434d0e3f51d31a4d4b8", + measurementId: "G-LZJKRYBSJV", +}); + +const messaging = firebase.messaging(); + +// 백그라운드 메시지 처리 +messaging.onBackgroundMessage((payload) => { + console.log("Received background message ", payload); + const notificationTitle = payload.notification.title; + const notificationOptions = { + body: payload.notification.body, + icon: "/icon.png", // 아이콘 이미지 경로 + }; + + self.registration.showNotification(notificationTitle, notificationOptions); +}); diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js deleted file mode 100644 index d96354f..0000000 --- a/public/mockServiceWorker.js +++ /dev/null @@ -1,294 +0,0 @@ -/* eslint-disable */ -/* tslint:disable */ - -/** - * Mock Service Worker. - * @see https://github.com/mswjs/msw - * - Please do NOT modify this file. - * - Please do NOT serve this file on production. - */ - -const PACKAGE_VERSION = '2.5.1' -const INTEGRITY_CHECKSUM = '07a8241b182f8a246a7cd39894799a9e' - -const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') -const activeClientIds = new Set() - -self.addEventListener('install', function () { - self.skipWaiting() -}) - -self.addEventListener('activate', function (event) { - event.waitUntil(self.clients.claim()) -}) - -self.addEventListener('message', async function (event) { - const clientId = event.source.id - - if (!clientId || !self.clients) { - return - } - - const client = await self.clients.get(clientId) - - if (!client) { - return - } - - const allClients = await self.clients.matchAll({ - type: 'window', - }) - - switch (event.data) { - case 'KEEPALIVE_REQUEST': { - sendToClient(client, { - type: 'KEEPALIVE_RESPONSE', - }) - break - } - - case 'INTEGRITY_CHECK_REQUEST': { - sendToClient(client, { - type: 'INTEGRITY_CHECK_RESPONSE', - payload: { - packageVersion: PACKAGE_VERSION, - checksum: INTEGRITY_CHECKSUM, - }, - }) - break - } - - case 'MOCK_ACTIVATE': { - activeClientIds.add(clientId) - - sendToClient(client, { - type: 'MOCKING_ENABLED', - payload: { - client: { - id: client.id, - frameType: client.frameType, - }, - }, - }) - break - } - - case 'MOCK_DEACTIVATE': { - activeClientIds.delete(clientId) - break - } - - case 'CLIENT_CLOSED': { - activeClientIds.delete(clientId) - - const remainingClients = allClients.filter((client) => { - return client.id !== clientId - }) - - // Unregister itself when there are no more clients - if (remainingClients.length === 0) { - self.registration.unregister() - } - - break - } - } -}) - -self.addEventListener('fetch', function (event) { - const { request } = event - - // Bypass navigation requests. - if (request.mode === 'navigate') { - return - } - - // Opening the DevTools triggers the "only-if-cached" request - // that cannot be handled by the worker. Bypass such requests. - if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { - return - } - - // Bypass all requests when there are no active clients. - // Prevents the self-unregistered worked from handling requests - // after it's been deleted (still remains active until the next reload). - if (activeClientIds.size === 0) { - return - } - - // Generate unique request ID. - const requestId = crypto.randomUUID() - event.respondWith(handleRequest(event, requestId)) -}) - -async function handleRequest(event, requestId) { - const client = await resolveMainClient(event) - const response = await getResponse(event, client, requestId) - - // Send back the response clone for the "response:*" life-cycle events. - // Ensure MSW is active and ready to handle the message, otherwise - // this message will pend indefinitely. - if (client && activeClientIds.has(client.id)) { - ;(async function () { - const responseClone = response.clone() - - sendToClient( - client, - { - type: 'RESPONSE', - payload: { - requestId, - isMockedResponse: IS_MOCKED_RESPONSE in response, - type: responseClone.type, - status: responseClone.status, - statusText: responseClone.statusText, - body: responseClone.body, - headers: Object.fromEntries(responseClone.headers.entries()), - }, - }, - [responseClone.body], - ) - })() - } - - return response -} - -// Resolve the main client for the given event. -// Client that issues a request doesn't necessarily equal the client -// that registered the worker. It's with the latter the worker should -// communicate with during the response resolving phase. -async function resolveMainClient(event) { - const client = await self.clients.get(event.clientId) - - if (activeClientIds.has(event.clientId)) { - return client - } - - if (client?.frameType === 'top-level') { - return client - } - - const allClients = await self.clients.matchAll({ - type: 'window', - }) - - return allClients - .filter((client) => { - // Get only those clients that are currently visible. - return client.visibilityState === 'visible' - }) - .find((client) => { - // Find the client ID that's recorded in the - // set of clients that have registered the worker. - return activeClientIds.has(client.id) - }) -} - -async function getResponse(event, client, requestId) { - const { request } = event - - // Clone the request because it might've been already used - // (i.e. its body has been read and sent to the client). - const requestClone = request.clone() - - function passthrough() { - const headers = Object.fromEntries(requestClone.headers.entries()) - - // Remove internal MSW request header so the passthrough request - // complies with any potential CORS preflight checks on the server. - // Some servers forbid unknown request headers. - delete headers['x-msw-intention'] - - return fetch(requestClone, { headers }) - } - - // Bypass mocking when the client is not active. - if (!client) { - return passthrough() - } - - // Bypass initial page load requests (i.e. static assets). - // The absence of the immediate/parent client in the map of the active clients - // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet - // and is not ready to handle requests. - if (!activeClientIds.has(client.id)) { - return passthrough() - } - - // Notify the client that a request has been intercepted. - const requestBuffer = await request.arrayBuffer() - const clientMessage = await sendToClient( - client, - { - type: 'REQUEST', - payload: { - id: requestId, - url: request.url, - mode: request.mode, - method: request.method, - headers: Object.fromEntries(request.headers.entries()), - cache: request.cache, - credentials: request.credentials, - destination: request.destination, - integrity: request.integrity, - redirect: request.redirect, - referrer: request.referrer, - referrerPolicy: request.referrerPolicy, - body: requestBuffer, - keepalive: request.keepalive, - }, - }, - [requestBuffer], - ) - - switch (clientMessage.type) { - case 'MOCK_RESPONSE': { - return respondWithMock(clientMessage.data) - } - - case 'PASSTHROUGH': { - return passthrough() - } - } - - return passthrough() -} - -function sendToClient(client, message, transferrables = []) { - return new Promise((resolve, reject) => { - const channel = new MessageChannel() - - channel.port1.onmessage = (event) => { - if (event.data && event.data.error) { - return reject(event.data.error) - } - - resolve(event.data) - } - - client.postMessage( - message, - [channel.port2].concat(transferrables.filter(Boolean)), - ) - }) -} - -async function respondWithMock(response) { - // Setting response status code to 0 is a no-op. - // However, when responding with a "Response.error()", the produced Response - // instance will have status code set to 0. Since it's not possible to create - // a Response instance with status code 0, handle that use-case separately. - if (response.status === 0) { - return Response.error() - } - - const mockedResponse = new Response(response.body, response) - - Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { - value: true, - enumerable: true, - }) - - return mockedResponse -} diff --git a/src/App.tsx b/src/App.tsx index 39f3022..125ef7f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,12 +2,15 @@ import { QueryClientProvider } from "@tanstack/react-query"; import Router from "./router/Router"; import { queryClient } from "./api/instance"; import { AuthProvider } from "./provider/AuthProvider"; +import { ModalProvider } from "./context/LoginModalContext"; function App() { return ( - + + + ); diff --git a/src/api/firebaseConfig.ts b/src/api/firebaseConfig.ts new file mode 100644 index 0000000..917a7bb --- /dev/null +++ b/src/api/firebaseConfig.ts @@ -0,0 +1,54 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { initializeApp } from "firebase/app"; +// eslint-disable-next-line import/no-extraneous-dependencies +import { + getMessaging, + getToken, + onMessage as firebaseOnMessage, +} from "firebase/messaging"; +// 환경 변수에서 Firebase 설정값 가져오기 +const firebaseConfig = { + apiKey: import.meta.env.VITE_FIREBASE_API_KEY, + authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN, + projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID, + storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET, + messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID, + appId: import.meta.env.VITE_FIREBASE_APP_ID, + measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID, +}; +// Firebase 초기화 +const app = initializeApp(firebaseConfig); +const messaging = getMessaging(app); +// VAPID 키 가져오기 +const vapidKey = import.meta.env.VITE_FIREBASE_VAPID_KEY; +// FCM 토큰 요청 함수 +export const requestForToken = async () => { + try { + const currentToken = await getToken(messaging, { vapidKey }); + if (currentToken) { + console.log("FCM token:", currentToken); + return currentToken; + } + console.log( + "No registration token available. Request permission to generate one.", + ); + return null; + } catch (error) { + console.error("An error occurred while retrieving token. ", error); + return null; + } +}; +// 메시지 수신 리스너 설정 +export const setupOnMessageListener = () => { + firebaseOnMessage(messaging, (payload) => { + console.log("Message received: ", payload); + const notificationTitle = payload.notification?.title || "알림"; + const notificationOptions = { + body: payload.notification?.body || "새로운 알림이 도착했습니다.", + }; + // 브라우저 알림 표시 + // eslint-disable-next-line no-new + new Notification(notificationTitle, notificationOptions); + }); +}; +export { messaging }; diff --git a/src/api/hooks/useCreatePlan.ts b/src/api/hooks/useCreatePlan.ts new file mode 100644 index 0000000..57982a8 --- /dev/null +++ b/src/api/hooks/useCreatePlan.ts @@ -0,0 +1,58 @@ +// import { useMutation } from "@tanstack/react-query"; +// import { apiClient } from "../instance"; + +// interface PlanCard { +// title: string; +// description: string; +// startDate: string; +// endDate: string; +// accessibility?: boolean; +// isCompleted?: boolean; +// } +// interface SavePlanData { +// deviceId: string; +// groupId: string; +// planCards: PlanCard[]; +// } +// const savePlan = async (data: SavePlanData) => { +// const response = await apiClient.post("/api/gpt/plan/save", data); +// return response.data; +// }; + +// const useCreatePlan = (p0: { +// onSuccess: (data: any) => void; +// onError: (error: any) => void; +// }) => { +// return useMutation({ +// mutationFn: savePlan, +// onSuccess: p0.onSuccess, +// onError: p0.onError, +// }); +// }; +// export default useCreatePlan; + +import { useMutation, UseMutationOptions } from "@tanstack/react-query"; +import { AxiosResponse } from "axios"; +import { apiClient } from "@/api/instance"; + +interface SavePlanParams { + plan: { + title: string; + description: string; + startDate: string; // ISO format string + endDate: string; // ISO format string + accessibility: boolean; + isCompleted: boolean; + }; +} + +const useCreatePlan = ( + options?: UseMutationOptions, Error, SavePlanParams>, +) => { + return useMutation, Error, SavePlanParams>({ + mutationFn: ({ plan }) => apiClient.post(`/api/plans`, plan), + ...options, + }); +}; + +export default useCreatePlan; diff --git a/src/api/hooks/useCreatePlans.ts b/src/api/hooks/useCreatePlans.ts deleted file mode 100644 index adcab3f..0000000 --- a/src/api/hooks/useCreatePlans.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { apiClient } from "@/api/instance"; - -interface CreatePlanData { - title: string; - description: string; - startDate: string; - endDate: string; - accessibility: boolean; - isCompleted: boolean; -} - -// 플랜 생성 함수 -const createPlan = async (planData: CreatePlanData) => { - const response = await apiClient.post("/api/plans", planData); - return response.data; -}; - -const useCreatePlan = () => { - return useMutation({ - mutationFn: createPlan, - onSuccess: (data) => { - console.log("플랜 생성 성공: ", data); - }, - onError: (error) => { - console.error("플랜 생성 실패: ", error); - }, - }); -}; - -export default useCreatePlan; diff --git a/src/api/hooks/useDeleteFriend.ts b/src/api/hooks/useDeleteFriend.ts index fdb36bd..3ac61bf 100644 --- a/src/api/hooks/useDeleteFriend.ts +++ b/src/api/hooks/useDeleteFriend.ts @@ -8,7 +8,6 @@ const useDeleteFriend = (friendId: number) => { return useMutation({ mutationFn: () => apiClient.delete(`/api/friends/${friendId}`), onSuccess: () => { - alert("친구가 목록에서 삭제되었습니다"); queryClient.invalidateQueries({ queryKey: ["friends"] }); }, }); diff --git a/src/api/hooks/useDeletePlans.ts b/src/api/hooks/useDeletePlan.ts similarity index 100% rename from src/api/hooks/useDeletePlans.ts rename to src/api/hooks/useDeletePlan.ts diff --git a/src/api/hooks/useDeletePlanCard.ts b/src/api/hooks/useDeletePlanCard.ts deleted file mode 100644 index 8eb953b..0000000 --- a/src/api/hooks/useDeletePlanCard.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { apiClient } from "../instance"; - -interface DeletePlanCardParams { - deviceId: string; - groupId: string; - cardId: string; -} - -// 특정 플랜 카드를 삭제하는 API 요청 함수 -const deletePlanCard = async ({ - deviceId, - groupId, - cardId, -}: DeletePlanCardParams) => { - return apiClient.delete( - `/api/preview-plan/card/${deviceId}/${groupId}/${cardId}`, - ); -}; - -// React Query와 useMutation을 사용하는 플랜 카드 삭제 훅 - -const useDeletePlanCard = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: deletePlanCard, - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ["planCards"], - }); - }, - onError: (error) => { - console.error("플랜 카드 삭제 실패", error); - }, - }); -}; - -export default useDeletePlanCard; diff --git a/src/api/hooks/useFriendRequest.ts b/src/api/hooks/useFriendRequest.ts index 6194292..be572d7 100644 --- a/src/api/hooks/useFriendRequest.ts +++ b/src/api/hooks/useFriendRequest.ts @@ -17,7 +17,7 @@ export const useFriendRequest = () => { } catch (err) { const axiosError = err as AxiosError; if (axiosError.response?.status === 400) { - alert("이미 친구 요청을 보냈습니다."); + alert("이미 친구이거나 요청을 보낸 상태입니다."); } else { setError("친구 요청 전송 중 오류가 발생했습니다."); console.error("친구 요청 오류:", axiosError); diff --git a/src/api/hooks/useGenerateDeviceId.ts b/src/api/hooks/useGenerateDeviceId.ts index 5b530f0..c3a913f 100644 --- a/src/api/hooks/useGenerateDeviceId.ts +++ b/src/api/hooks/useGenerateDeviceId.ts @@ -1,31 +1,24 @@ import { useQuery } from "@tanstack/react-query"; -import { useCookies } from "react-cookie"; import { apiClient } from "@/api/instance"; -const setDeviceIdCookie = (deviceId: string) => { - document.cookie = `device_id=${deviceId}; path=/; Secure; SameSite=Strict`; -}; +// const setDeviceIdCookie = (deviceId: string) => { +// document.cookie = `device_id=${deviceId}; path=/; Secure; SameSite=Strict`; +// }; const fetchDeviceId = async () => { const response = await apiClient.get(`/api/gpt/generate-device-id`); const deviceId = response.data; // deviceId를 쿠키에 저장 - setDeviceIdCookie(deviceId); + // setDeviceIdCookie(deviceId); return deviceId; }; const useGenerateDeviceId = () => { - const [cookies] = useCookies(["device_id"]); - const deviceId = cookies.device_id; - - // deviceId가 쿠키에 이미 존재하는 경우, API 호출을 생략하고 기존 값 반환 return useQuery({ queryKey: ["deviceId"], queryFn: fetchDeviceId, - enabled: !deviceId, // 쿠키에 deviceId가 없을 때만 호출 - initialData: deviceId, // 쿠키에 있는 deviceId를 초기값으로 설정 }); }; diff --git a/src/api/hooks/useTeamPlan.ts b/src/api/hooks/useGeneratePlans.ts similarity index 52% rename from src/api/hooks/useTeamPlan.ts rename to src/api/hooks/useGeneratePlans.ts index 9201bb1..cd8cfbb 100644 --- a/src/api/hooks/useTeamPlan.ts +++ b/src/api/hooks/useGeneratePlans.ts @@ -1,8 +1,8 @@ import { useMutation, UseMutationOptions } from "@tanstack/react-query"; +import { AxiosError } from "axios"; import { apiClient } from "@/api/instance"; import { transformPlanData } from "./useGetPlans"; import { CalendarEvent } from "@/components/features/CustomCalendar/CustomCalendar"; - // 요청 파라미터 타입 정의 interface GptRequestParams { deviceId: string; @@ -34,25 +34,49 @@ const fetchGptData = async ( // 레벨별 훅 export const useGptLight = ( - options?: UseMutationOptions, + options?: UseMutationOptions, ) => - useMutation({ + useMutation({ mutationFn: (params) => fetchGptData("/api/gpt/member/light", params), ...options, }); export const useGptModerate = ( - options?: UseMutationOptions, + options?: UseMutationOptions, ) => - useMutation({ + useMutation({ mutationFn: (params) => fetchGptData("/api/gpt/member/moderate", params), ...options, }); export const useGptStrong = ( - options?: UseMutationOptions, + options?: UseMutationOptions, ) => - useMutation({ + useMutation({ mutationFn: (params) => fetchGptData("/api/gpt/member/strong", params), ...options, }); + +export const useGptTrialLight = ( + options?: UseMutationOptions, +) => + useMutation({ + mutationFn: (params) => fetchGptData("/api/gpt/trial/light", params), + ...options, + }); + +export const useGptTrialModerate = ( + options?: UseMutationOptions, +) => + useMutation({ + mutationFn: (params) => fetchGptData("/api/gpt/trial/moderate", params), + ...options, + }); + +export const useGptTrialStrong = ( + options?: UseMutationOptions, +) => + useMutation({ + mutationFn: (params) => fetchGptData("/api/gpt/trial/strong", params), + ...options, + }); diff --git a/src/api/hooks/useGetPlanCard.ts b/src/api/hooks/useGetPlanCard.ts deleted file mode 100644 index 1f5ca6b..0000000 --- a/src/api/hooks/useGetPlanCard.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { apiClient } from "@/api/instance"; - -export interface PlanCard { - deviceId: string; - groupId: string; - cardId: string; - title: string; - description: string; - startDate: string; - endDate: string; -} - -interface planGroupResponse { - deviceId: string; - groupId: string; - planCards: PlanCard[]; -} - -const fetchPlans = async (deviceId: string): Promise => { - console.log("Fetching plans with deviceId:", deviceId); // deviceId 로그 확인 - const response = await apiClient.get( - `/api/preview-plan/`, - { - params: { deviceId }, - }, - ); - - console.log("API response data:", response.data); - return response.data; -}; - -const useGetPlanCard = (deviceId: string) => { - return useQuery({ - queryKey: ["fetchPlans", deviceId], - queryFn: () => fetchPlans(deviceId), - enabled: !!deviceId, - }); -}; - -export default useGetPlanCard; diff --git a/src/api/hooks/useGetPlans.ts b/src/api/hooks/useGetPlans.ts index 9636f4b..4ef3d16 100644 --- a/src/api/hooks/useGetPlans.ts +++ b/src/api/hooks/useGetPlans.ts @@ -30,7 +30,19 @@ export const fetchPlans = async (): Promise => { export const useGetPlans = () => { return useQuery({ queryKey: ["plans"], - queryFn: fetchPlans, + queryFn: async () => { + const data = await fetchPlans(); // 변환된 데이터를 가져옴 + + // KST로 변환하는 로직 추가 + const KSTOffset = 9 * 60 * 60 * 1000; + const dataInKST = data.map((plan) => ({ + ...plan, + start: new Date(plan.start.getTime() + KSTOffset), + end: new Date(plan.end.getTime() + KSTOffset), + })); + + return dataInKST; + }, }); }; diff --git a/src/api/hooks/useGetTeamPlan.ts b/src/api/hooks/useGetTeamPlan.ts index bd1c360..9d9859a 100644 --- a/src/api/hooks/useGetTeamPlan.ts +++ b/src/api/hooks/useGetTeamPlan.ts @@ -22,8 +22,6 @@ const useGetTeamPlans = (teamId: number) => { enabled: !!teamId, // teamId가 있을 때만 요청 실행 }); - console.log("useGetTeamPlans hook result:", result); // 훅의 반환 데이터 로그 - console.log("team number:", teamId); return result; }; diff --git a/src/api/hooks/useGptRequest.ts b/src/api/hooks/useGptRequest.ts deleted file mode 100644 index 7d4da3e..0000000 --- a/src/api/hooks/useGptRequest.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { apiClient } from "@/api/instance"; - -interface GptRequestData { - deviceId: string; - text: string; -} -interface GptResponse { - groupId: string; - planCards: any[]; // 정확한 구조를 알고 있다면 any 대신 구체적인 타입으로 지정하세요. -} -const sendGptRequest = async ( - level: "light" | "moderate" | "strong", - data: GptRequestData, -): Promise => { - const response = await apiClient.post( - `/api/gpt/member/${level}?deviceId=${data.deviceId}`, - { - text: data.text, - }, - ); - return response.data; -}; -const useGptRequest = () => { - return useMutation({ - mutationFn: async (data: GptRequestData) => { - const levels = ["light", "moderate", "strong"] as const; - const responses = await Promise.all( - levels.map((level) => sendGptRequest(level, data)), - ); - return responses; - }, - }); -}; -export default useGptRequest; diff --git a/src/api/hooks/useGptTrial.ts b/src/api/hooks/useGptTrial.ts deleted file mode 100644 index 538be22..0000000 --- a/src/api/hooks/useGptTrial.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { apiClient } from "@/api/instance"; - -interface GptRequestData { - deviceId: string; - text: string; -} -interface GptResponse { - groupId: string; - planCards: any[]; // 정확한 구조를 알고 있다면 any 대신 구체적인 타입으로 지정하세요. -} -const sendGptRequest = async ( - level: "light" | "moderate" | "strong", - data: GptRequestData, -): Promise => { - const response = await apiClient.post( - `/api/gpt/trial/${level}?deviceId=${data.deviceId}`, - { - text: data.text, - }, - ); - return response.data; -}; -const useGptTrial = () => { - return useMutation({ - mutationFn: async (data: GptRequestData) => { - const levels = ["light", "moderate", "strong"] as const; - const responses = await Promise.all( - levels.map((level) => sendGptRequest(level, data)), - ); - return responses; - }, - }); -}; -export default useGptTrial; diff --git a/src/api/hooks/useModifyPlans.ts b/src/api/hooks/useModifyPlans.ts new file mode 100644 index 0000000..093760e --- /dev/null +++ b/src/api/hooks/useModifyPlans.ts @@ -0,0 +1,34 @@ +import { useMutation, UseMutationOptions } from "@tanstack/react-query"; +import { AxiosResponse } from "axios"; +import { apiClient } from "@/api/instance"; + +interface ModifyPlanParams { + planId: number; + planData: { + title: string; + description: string; + startDate: string; + endDate: string; + accessibility: boolean; + isCompleted: boolean; + }; +} + +const ModifyPlan = async ({ + planId, + planData, +}: ModifyPlanParams): Promise> => { + return apiClient.put(`/api/plans/${planId}`, planData); +}; + +// useModifyPlan 훅 정의 +const useModifyPlan = ( + options?: UseMutationOptions, Error, ModifyPlanParams>, +) => { + return useMutation, Error, ModifyPlanParams>({ + mutationFn: ModifyPlan, + ...options, + }); +}; + +export default useModifyPlan; diff --git a/src/api/hooks/useSavePlan.ts b/src/api/hooks/useSavePlan.ts deleted file mode 100644 index 286bdc2..0000000 --- a/src/api/hooks/useSavePlan.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { apiClient } from "../instance"; - -interface PlanCard { - title: string; - description: string; - startDate: string; - endDate: string; - accessibility?: boolean; - isCompleted?: boolean; -} -interface SavePlanData { - deviceId: string; - groupId: string; - planCards: PlanCard[]; -} -const savePlan = async (data: SavePlanData) => { - const response = await apiClient.post("/api/gpt/plan/save", data); - return response.data; -}; -const useSavePlan = () => { - return useMutation({ - mutationFn: savePlan, - }); -}; -export default useSavePlan; diff --git a/src/api/hooks/useSavePreviewPlan.ts b/src/api/hooks/useSavePreviewPlan.ts deleted file mode 100644 index d708eb4..0000000 --- a/src/api/hooks/useSavePreviewPlan.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { apiClient } from "../instance"; - -interface PreviewPlanData { - title: string; - description: string; - startDate: string; // ISO 형식의 날짜 문자열 - endDate: string; // ISO 형식의 날짜 문자열 -} - -interface SavePreviewPlanParams { - deviceId: string; - groupId: string; - planDataList: PreviewPlanData[]; -} - -// 프리뷰 플랜 저장 함수 -const savePreviewPlan = async ({ - deviceId, - groupId, - planDataList, -}: SavePreviewPlanParams) => { - const response = await apiClient.post( - `/api/plans/save-preview/${deviceId}/${groupId}`, - { plans: planDataList }, - ); - return response.data; -}; - -// 커스텀 훅 -const useSavePreviewPlan = () => { - return useMutation({ - mutationFn: (params: SavePreviewPlanParams) => savePreviewPlan(params), - onSuccess: (data) => { - console.log("프리뷰 플랜 저장 성공:", data); - }, - onError: (error) => { - console.error("프리뷰 플랜 저장 실패:", error); - }, - }); -}; - -export default useSavePreviewPlan; diff --git a/src/api/hooks/useTeam.ts b/src/api/hooks/useTeam.ts index 4f1c479..a8593c2 100644 --- a/src/api/hooks/useTeam.ts +++ b/src/api/hooks/useTeam.ts @@ -1,10 +1,18 @@ -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + useQuery, + useMutation, + UseMutationOptions, + useQueryClient, + UseMutationResult, +} from "@tanstack/react-query"; +import { AxiosResponse, AxiosError } from "axios"; import { apiClient } from "@/api/instance"; import { Team, Invitation, InviteUserParams, SentInvitation, + TeamMember, } from "@/types/types"; // 팀 목록을 가져오는 API 요청 함수 @@ -119,3 +127,86 @@ export const useFetchSentInvitations = (teamId: number) => { retry: false, }); }; + +// 관리자: 팀에서 유저를 내보내는 훅 +interface RemoveUserParams { + teamId: number; + userId: number; +} + +export const useRemoveUserFromTeam = ( + options?: UseMutationOptions, Error, RemoveUserParams>, +) => { + return useMutation, Error, RemoveUserParams>({ + mutationFn: ({ teamId, userId }) => + apiClient.delete(`/api/teams/${teamId}/users/${userId}`), + ...options, + }); +}; + +// 관리자: 유저 권한 수정 +interface UpdateUserRoleParams { + teamId: number; + userId: number; + role: string; // 예: 'admin', 'member' 등 +} + +export const useUpdateUserRole = ( + options?: UseMutationOptions, Error, UpdateUserRoleParams>, +) => { + return useMutation, Error, UpdateUserRoleParams>({ + mutationFn: ({ teamId, userId }) => + apiClient.put(`/api/teams/${teamId}/users/${userId}/role`), + ...options, + }); +}; + +export function useGetTeamMembers(teamId: number) { + return useQuery({ + queryKey: ["teamMembers", teamId], + queryFn: () => + apiClient.get(`/api/teams/${teamId}/members`).then((res) => res.data), + }); +} + +interface CancelInvitationParams { + invitationId: number; + teamId: number; +} + +export const useCancelTeamInvitation = (): UseMutationResult< + void, + AxiosError, + CancelInvitationParams, + unknown +> => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ invitationId }: CancelInvitationParams) => { + const response = await apiClient.delete( + `/api/teams/invitation/${invitationId}/cancel`, + ); + + // 204 No Content 응답 확인 + if (response.status !== 204) { + throw new Error("초대 취소 중 예상치 못한 응답을 받았습니다."); + } + }, + onSuccess: (_, variables) => { + // 'sentInvitations' 쿼리를 무효화하여 최신 데이터로 갱신 + queryClient.invalidateQueries({ + queryKey: ["sentInvitations", variables.teamId], + }); + }, + onError: (error: AxiosError) => { + if (error.response?.status === 403) { + alert("권한이 없습니다."); + } else if (error.response?.status === 404) { + alert("초대를 찾을 수 없습니다."); + } else { + alert(`초대 취소 중 오류가 발생했습니다: ${error.message}`); + } + }, + }); +}; diff --git a/src/api/hooks/useUpdatePlanCard.ts b/src/api/hooks/useUpdatePlanCard.ts deleted file mode 100644 index af969e5..0000000 --- a/src/api/hooks/useUpdatePlanCard.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { apiClient } from "../instance"; - -export interface UpdatePlanCardData { - title: string; - description: string; - startDate: string; - endDate: string; -} - -// mutation 파라미터 타입 정의 -interface UpdatePlanCardVariables { - deviceId: string; - groupId: string; - cardId: string; - planData: UpdatePlanCardData; -} - -// updatePlanCard.tsx -const updatePlanCard = async ( - deviceId: string, - groupId: string, - cardId: string, - planData: UpdatePlanCardData, -) => { - try { - console.log("API 호출 시작:", { - url: `/api/preview-plan/card/${deviceId}/${groupId}/${cardId}`, - data: planData, - }); - - const response = await apiClient.put( - `/api/preview-plan/card/${deviceId}/${groupId}/${cardId}`, - planData, - ); - - console.log("API 응답 성공:", response.data); - return response.data; - } catch (error: any) { - console.error("API 호출 실패:", { - message: error.message, - status: error.response?.status, - statusText: error.response?.statusText, - serverError: error.response?.data, - requestURL: error.config?.url, - requestMethod: error.config?.method, - requestData: error.config?.data, - }); - throw error; - } -}; - -const useUpdatePlanCard = () => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: ({ - deviceId, - groupId, - cardId, - planData, - }: UpdatePlanCardVariables) => { - console.log("mutation 시작:", { - deviceId, - groupId, - cardId, - planData, - }); - return updatePlanCard(deviceId, groupId, cardId, planData); - }, - - onSuccess: (data, variables: UpdatePlanCardVariables) => { - console.log("플랜 카드 수정 성공:", data); - - queryClient.invalidateQueries({ - queryKey: ["planCards", variables.groupId], - }); - - queryClient.invalidateQueries({ - queryKey: ["planCard", variables.cardId], - }); - }, - - onError: (error: any) => { - console.error("mutation 에러:", error); - console.error("플랜 카드 수정 실패 상세 정보:", { - message: error.message, - status: error.response?.status, - statusText: error.response?.statusText, - serverError: error.response?.data, - requestURL: error.config?.url, - requestMethod: error.config?.method, - requestData: error.config?.data, - }); - }, - }); -}; - -export default useUpdatePlanCard; diff --git a/src/api/hooks/useUpdatePlans.ts b/src/api/hooks/useUpdatePlans.ts deleted file mode 100644 index f612016..0000000 --- a/src/api/hooks/useUpdatePlans.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { apiClient } from "@/api/instance"; - -interface UpdatePlanData { - title: string; - description: string; - startDate: string; - endDate: string; - accessibility: boolean; - isCompleted: boolean; -} - -// 플랜 수정 함수 -const updatePlan = async (planId: number, planData: UpdatePlanData) => { - const response = await apiClient.put(`api/plans/${planId}`, planData); - return response.data; -}; - -const useUpdatePlans = () => { - return useMutation({ - mutationFn: ({ - planId, - planData, - }: { - planId: number; - planData: UpdatePlanData; - }) => updatePlan(planId, planData), - onSuccess: (data) => { - console.log("플랜 수정 성공: ", data); - }, - onError: (error) => { - console.error("플랜 수정 실패: ", error); - }, - }); -}; - -export default useUpdatePlans; diff --git a/src/api/instance/index.ts b/src/api/instance/index.ts index 171c38a..e2d65d2 100644 --- a/src/api/instance/index.ts +++ b/src/api/instance/index.ts @@ -2,9 +2,9 @@ import axios from "axios"; import type { AxiosInstance, AxiosRequestConfig } from "axios"; import { QueryClient } from "@tanstack/react-query"; - +import EventEmitter from "events"; // BASE URL 설정 -const API_URL = import.meta.env.VITE_API_URL || "http://localhost:5173"; +const API_URL = import.meta.env.VITE_API_URL; // 쿠키에서 특정 토큰을 가져오는 로직 const getCookie = (name: string) => { const value = `; ${document.cookie}`; @@ -13,13 +13,17 @@ const getCookie = (name: string) => { return null; }; +export const authEventEmitter = new EventEmitter(); + // Axios 인스턴스 초기화 함수 const initInstance = (axiosConfig: AxiosRequestConfig = {}): AxiosInstance => { + const accessToken = getCookie("access_token"); const instance = axios.create({ baseURL: API_URL, headers: { Accept: "application/json", "Content-Type": "application/json", + ...(accessToken && { Authorization: `Bearer ${accessToken}` }), ...axiosConfig.headers, }, }); @@ -31,18 +35,19 @@ const initInstance = (axiosConfig: AxiosRequestConfig = {}): AxiosInstance => { const originalRequest = error.config; if ( error.response && - error.response.status === 401 && + (error.response.status === 401 || error.response.status === 403) && !originalRequest._retry ) { originalRequest._retry = true; // 쿠키에서 리프레시 토큰 가져오기 const refreshToken = getCookie("refresh_token"); + const deviceId = getCookie("device_id"); if (refreshToken) { try { - const { data } = await axios.post(`${API_URL}/token/refresh`, { - refresh_token: refreshToken, - }); + const { data } = await axios.post( + `${API_URL}/api/token/refresh?refreshToken=${refreshToken}&deviceId=${deviceId}`, + ); const newAccessToken = data; // 새로운 액세스 토큰을 쿠키에 저장 @@ -55,7 +60,10 @@ const initInstance = (axiosConfig: AxiosRequestConfig = {}): AxiosInstance => { return await instance(originalRequest); } catch (refreshError) { console.error("리프레시 토큰 갱신 실패:", refreshError); + authEventEmitter.emit("logout"); } + } else { + authEventEmitter.emit("logout"); } } return Promise.reject(error); diff --git a/src/assets/icon.png b/src/assets/icon.png new file mode 100644 index 0000000..bdc825f Binary files /dev/null and b/src/assets/icon.png differ diff --git a/src/assets/kakao_logo.svg b/src/assets/kakao_logo.svg deleted file mode 100644 index da1aa20..0000000 --- a/src/assets/kakao_logo.svg +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/src/components/common/Button/Button.tsx b/src/components/common/Button/Button.tsx index e84227a..66a8458 100644 --- a/src/components/common/Button/Button.tsx +++ b/src/components/common/Button/Button.tsx @@ -1,11 +1,11 @@ -// Button.tsx +// src/components/common/Button/Button.tsx import React from "react"; import StyledButton from "./StyledButton.styles"; import { Props } from "./Button.types"; const Button: React.FC = ({ theme = "primary", - size = "responsive", // 기본값 responsive + size = "responsive", onClick, disabled, type, diff --git a/src/components/common/Button/StyledButton.styles.ts b/src/components/common/Button/StyledButton.styles.ts index 09f2b90..735eb0e 100644 --- a/src/components/common/Button/StyledButton.styles.ts +++ b/src/components/common/Button/StyledButton.styles.ts @@ -1,104 +1,100 @@ -// Button.styles.ts +// src/components/common/Button/StyledButton.styles.ts import styled from "@emotion/styled"; import breakpoints from "@/variants/breakpoints"; import { Props } from "./Button.types"; -const StyledButton = styled.button>( - { - borderRadius: "16px", - display: "flex", - justifyContent: "center", - alignItems: "center", - cursor: "pointer", - transition: "background-color 200ms", - padding: "0 16px", - outline: "none", - "&:focus": { - outline: "none", - }, - }, - ({ size = "responsive" }) => { - const smallStyle = { - width: "100px", - height: "40px", - fontSize: "14px", - }; +const StyledButton = styled.button>` + display: inline-flex; + justify-content: center; + align-items: center; + border-radius: 10px; + border: none; + font-weight: 500; + cursor: pointer; + transition: + background-color 0.2s ease, + color 0.2s ease; + padding: 8px 16px; /* py-2 px-4 */ + outline: none; + font-size: 16px; + min-width: 120px; + height: 40px; + &:focus { + outline: none; + box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.5); /* focus:ring-2 focus:ring-offset-2 */ + } - const largeStyle = { - width: "150px", - height: "50px", - fontSize: "20px", - }; - - const longStyle = { - width: "160px", - height: "45px", - fontSize: "15px", - }; - - if (size === "small") { - return smallStyle; - } - - if (size === "large") { - return largeStyle; - } - - if (size === "long") { - return longStyle; - } - - // 반응형 - return { - [breakpoints.mobile]: { - width: "100px", - height: "40px", - fontSize: "14px", - }, - width: "150px", - height: "50px", - fontSize: "20px", - }; - }, - ({ theme }) => { + ${({ theme }) => { switch (theme) { case "primary": - return { - backgroundColor: "#39A7F7", - color: "white", - border: "none", - "&:hover": { - backgroundColor: "#8FD0FF", - color: "black", - border: "none", - }, - }; + return ` + background-color: #2196F3; /* bg-[#2196F3] */ + color: #ffffff; /* text-white */ + &:hover { + background-color: #1E88E5; /* hover:bg-[#1E88E5] */ + } + &:focus { + box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.5); + } + `; case "secondary": - return { - backgroundColor: "#ffffff", - color: "#39A7F7", - border: "1px solid #39A7F7", - "&:hover": { - backgroundColor: "#DCDCDC", - color: "black", - border: "1px solid #39A7F7", - }, - }; + return ` + background-color: #E3F2FD; /* bg-[#E3F2FD] */ + color: #1976D2; /* text-[#1976D2] */ + &:hover { + background-color: #BBDEFB; /* hover:bg-[#BBDEFB] */ + } + &:focus { + box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.5); + } + `; case "kakao": - return { - backgroundColor: "#ffe401", - color: "#1e1e1f", - border: "none", - "&:hover": { - backgroundColor: "#ffcd00", - color: "#1a1a1a", - transition: "all 0.3s ease", - }, - }; + return ` + background-color: #ffe401; + color: #1e1e1f; + &:hover { + background-color: #ffcd00; + } + &:focus { + box-shadow: 0 0 0 2px rgba(255, 205, 0, 0.5); + } + `; + default: + return ` + background-color: #f0f4fa; + color: #4a4a4a; + `; + } + }} + + ${({ size }) => { + switch (size) { + case "small": + return ` + height: 36px; + font-size: 14px; + `; + case "large": + return ` + height: 48px; + font-size: 18px; + `; + case "long": + return ` + width: 100%; + height: 40px; + font-size: 16px; + `; default: - return {}; + return ` + @media (max-width: ${breakpoints.sm}px) { + height: 36px; + font-size: 14px; + min-width: 100px; + } + `; } - }, -); + }} +`; export default StyledButton; diff --git a/src/components/common/Input/Input.tsx b/src/components/common/Input/Input.tsx index 5432182..74af74a 100644 --- a/src/components/common/Input/Input.tsx +++ b/src/components/common/Input/Input.tsx @@ -1,4 +1,5 @@ -import StyledInput from "./StyledInput.styles"; +// src/components/common/Input/Input.tsx +import StyledInputContainer, { StyledInput } from "./StyledInput.styles"; export type Props = { onChange?: React.ChangeEventHandler; @@ -9,12 +10,14 @@ export type Props = { const Input: React.FC = ({ value, placeholder, onChange, ...props }) => { return ( - + + + ); }; diff --git a/src/components/common/Input/StyledInput.styles.ts b/src/components/common/Input/StyledInput.styles.ts index d212878..d1e565a 100644 --- a/src/components/common/Input/StyledInput.styles.ts +++ b/src/components/common/Input/StyledInput.styles.ts @@ -1,28 +1,49 @@ -// Input.styles.ts +// src/components/common/Input/StyledInput.styles.ts import styled from "@emotion/styled"; import breakpoints from "@/variants/breakpoints"; -const StyledInput = styled.textarea` - border-radius: 16px; - padding: 10px; - border: 3px solid #d5d5d5; - font-family: "Inter, system-ui, Avenir, Helvetica, Arial, sans-serif"; - font-weight: 600; - width: 80%; - margin: 0 30px; - text-align: left; - vertical-align: top; - max-width: 100%; - max-height: 300px; +const StyledInputContainer = styled.div` + margin-bottom: 24px; /* mb-6 */ + width: 100%; + display: flex; /* 플렉스 컨테이너로 변경 */ + justify-content: center; /* 수평 중앙 정렬 */ +`; + +export const StyledInput = styled.textarea` + width: 80%; /* 전체 너비의 80% 차지 */ + max-width: 500px; /* 데스크탑에서 최대 너비 420px */ + padding: 8px 12px; /* px-3 py-2 */ + font-size: 16px; + font-family: "Inter", sans-serif; + color: #4a5568; /* text-gray-700 */ + border: 1px solid #cbd5e0; /* border */ + border-radius: 8px; /* rounded-lg */ + resize: none; /* resize-none */ + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; /* transition duration-200 */ + + &:focus { + outline: none; /* focus:outline-none */ + border-color: #2196f3; /* focus:border-[#2196F3] */ + box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2); /* focus:ring-2 focus:ring-[#2196F3] */ + } + + &::placeholder { + color: #a0aec0; /* placeholder text color */ + } + + /* 기존 높이 유지 */ height: 450px; - font-size: 20px; - resize: none; + max-height: 300px; - // 모바일 + /* 모바일 반응형 스타일 */ ${breakpoints.mobile} { + width: 80%; + max-width: none; /* 모바일에서는 max-width 제한 해제 */ height: 250px; - font-size: 16px; + font-size: 14px; } `; -export default StyledInput; +export default StyledInputContainer; diff --git a/src/components/common/NumberButton/NumberButton.tsx b/src/components/common/NumberButton/NumberButton.tsx index 11b20b2..044c8d2 100644 --- a/src/components/common/NumberButton/NumberButton.tsx +++ b/src/components/common/NumberButton/NumberButton.tsx @@ -1,33 +1,34 @@ +// src/components/common/NumberButton/NumberButton.tsx import styled from "@emotion/styled"; import breakpoints from "@/variants/breakpoints"; const StyledNumberButton = styled.button<{ clicked: boolean }>` - width: 70px; - height: 70px; - background-color: ${({ clicked }) => (clicked ? "#39a7f7" : "#fff")}; - border: 2px solid #39a7f7; - border-radius: 16px; + width: 60px; + height: 60px; + background-color: ${({ clicked }) => (clicked ? "#39a7f7" : "#f0f4fa")}; + border: none; + border-radius: 50%; display: flex; align-items: center; justify-content: center; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); color: ${({ clicked }) => (clicked ? "#fff" : "#39a7f7")}; font-size: 20px; font-weight: bold; cursor: pointer; outline: none; + transition: + background-color 0.3s ease, + color 0.3s ease; &:hover { background-color: #39a7f7; - border: none; color: white; } ${breakpoints.tablet} { width: 50px; height: 50px; - font-size: 20px; - border-radius: 16px; + font-size: 18px; } `; diff --git a/src/components/common/Sidebar/Sidebar.styles.ts b/src/components/common/Sidebar/Sidebar.styles.ts index 2df5323..4c441df 100644 --- a/src/components/common/Sidebar/Sidebar.styles.ts +++ b/src/components/common/Sidebar/Sidebar.styles.ts @@ -1,32 +1,21 @@ +// src/components/common/Sidebar/Sidebar.styles.tsx import styled from "@emotion/styled"; -import { Link } from "react-router-dom"; import breakpoints from "@/variants/breakpoints"; -export const StyledLink = styled(Link)<{ selected: boolean }>` - color: ${({ selected }) => (selected ? "#fff" : "#000")}; - text-decoration: none; - display: block; /* 블록 요소로 변경하여 전체 영역을 클릭 가능하게 만듦 */ - width: 100%; - height: 100%; -`; - -// 사이드바 컨테이너 export const SidebarContainer = styled.div<{ isOpen: boolean }>( ({ isOpen }) => ({ - width: "200px", + width: "225px", // 16rem height: "100vh", - justifyContent: "space-between", - padding: "15px", - backgroundColor: "#f5f5f5", - position: "fixed", - top: 0, - left: 0, + padding: "24px", // 1.5rem + backgroundColor: "#F0F4FA", display: "flex", flexDirection: "column", boxSizing: "border-box", overflow: "hidden", + position: "fixed", + left: 0, + top: 0, zIndex: 1000, - transition: "height 0.3s ease, width 0.3s ease", // 모바일 스타일 @@ -39,20 +28,39 @@ export const SidebarContainer = styled.div<{ isOpen: boolean }>( }), ); -// 메뉴 아이템 컨테이너 +export const MobileHeader = styled.div({ + display: "none", + [breakpoints.mobile]: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + height: "70px", + backgroundColor: "#F0F4FA", + position: "fixed", + top: 0, + left: 0, + width: "100%", + boxSizing: "border-box", + }, +}); + +export const HamburgerMenu = styled.div({ + cursor: "pointer", + display: "flex", + alignItems: "center", + justifyContent: "center", + width: "40px", + height: "40px", +}); + export const MenuItemsContainer = styled.div<{ isOpen: boolean }>( ({ isOpen }) => ({ - display: "flex", - flexDirection: "column", flexGrow: 1, - justifyContent: "flex-start", width: "100%", boxSizing: "border-box", - alignItems: "center", - - // 모바일 스타일 + marginTop: "16px", [breakpoints.mobile]: { - paddingTop: "70px", // 헤더 아래에서 시작하도록 여백 추가 + paddingTop: "70px", maxHeight: isOpen ? "500px" : "0", opacity: isOpen ? 1 : 0, visibility: isOpen ? "visible" : "hidden", @@ -61,86 +69,54 @@ export const MenuItemsContainer = styled.div<{ isOpen: boolean }>( }), ); -// 메뉴 아이템 스타일 export const MenuItem = styled.div<{ selected: boolean }>(({ selected }) => ({ width: "100%", - padding: "10px 17px", + padding: "12px 16px", display: "flex", alignItems: "center", - borderRadius: "15px", - backgroundColor: selected ? "#39A7F7" : "transparent", - color: selected ? "#FFFFFF" : "#000000", - fontSize: "14px", - fontWeight: 600, - lineHeight: "22.52px", + borderRadius: "8px", + backgroundColor: selected ? "rgba(255, 255, 255, 0.8)" : "transparent", + color: selected ? "#39A7F7" : "#4A4A4A", + fontSize: "16px", + fontWeight: 500, cursor: "pointer", boxSizing: "border-box", - transition: "background-color 0.3s ease, box-shadow 0.3s ease", - marginBottom: "10px", + transition: "all 0.3s ease-in-out", + marginBottom: "8px", + boxShadow: selected ? "0 1px 2px 0 rgba(0,0,0,0.05)" : "none", "&:hover": { - backgroundColor: selected ? "#39A7F7" : "#E0E0E0", - boxShadow: "0px 4px 8px rgba(0, 0, 0, 0.1)", - }, - - ".icon": { - marginRight: "8px", - color: selected ? "#FFFFFF" : "#000000", + backgroundColor: "rgba(255, 255, 255, 0.5)", + color: "#39A7F7", }, })); -// 시간 및 날짜 표시 -export const TimeDisplay = styled.div({ - fontSize: "40px", - fontWeight: 700, - textAlign: "center", - marginTop: "auto", - width: "100%", - fontFamily: "monospace", -}); - -export const DateDisplay = styled.div({ - fontSize: "18px", - fontWeight: 700, - textAlign: "center", - width: "100%", -}); - -// 모바일 헤더 -export const MobileHeader = styled.div({ - display: "none", - // 모바일 스타일 - [breakpoints.mobile]: { +export const MenuItemIcon = styled.div<{ selected: boolean }>( + ({ selected }) => ({ display: "flex", - justifyContent: "space-between", alignItems: "center", + marginRight: "12px", + width: "20px", + height: "20px", + color: selected ? "#39A7F7" : "#6B7280", + transition: "color 0.3s ease-in-out", + }), +); - height: "70px", - backgroundColor: "#f5f5f5", - position: "fixed", - top: 0, - left: 0, - width: "100%", - boxSizing: "border-box", - }, -}); +export const MenuItemText = styled.span<{ selected: boolean }>( + ({ selected }) => ({ + fontSize: "16px", + color: selected ? "#39A7F7" : "#4A4A4A", + transition: "color 0.3s ease-in-out", + }), +); -// 햄버거 메뉴 -export const HamburgerMenu = styled.div({ - cursor: "pointer", - display: "flex", - alignItems: "center", - justifyContent: "center", - width: "40px", - height: "40px", +export const TimeDisplay = styled.div({ + fontSize: "14px", + color: "#718096", + marginTop: "8px", }); -export const MenuItemIcon = styled.div` - display: flex; - align-items: center; - margin-right: 8px; -`; - -export const MenuItemText = styled.span` - display: flex; - align-items: center; -`; +export const DateDisplay = styled.div({ + fontSize: "14px", + color: "#718096", +}); diff --git a/src/components/common/Sidebar/Sidebar.tsx b/src/components/common/Sidebar/Sidebar.tsx index 22c733c..a75885b 100644 --- a/src/components/common/Sidebar/Sidebar.tsx +++ b/src/components/common/Sidebar/Sidebar.tsx @@ -1,3 +1,4 @@ +// src/components/common/Sidebar/Sidebar.tsx import React, { useState, useEffect, useCallback } from "react"; import { Home, @@ -7,7 +8,7 @@ import { People, Menu, } from "@mui/icons-material"; -import { useNavigate, useLocation } from "react-router-dom"; // useLocation 추가 +import { useNavigate, useLocation } from "react-router-dom"; import breakpoints from "@/variants/breakpoints"; import { SidebarContainer, @@ -15,14 +16,13 @@ import { HamburgerMenu, MenuItemsContainer, MenuItem, - StyledLink, MenuItemIcon, MenuItemText, TimeDisplay, DateDisplay, } from "./Sidebar.styles"; import logo from "@/assets/logo.svg"; -// 고정된 메뉴 항목 타입 정의 + interface MenuItemType { name: string; icon: JSX.Element; @@ -41,34 +41,28 @@ const TimeComponent = () => { const [time, setTime] = useState(() => new Date()); useEffect(() => { - const timer = setInterval(() => { - setTime(new Date()); - }, 1000); - + const timer = setInterval(() => setTime(new Date()), 1000); return () => clearInterval(timer); }, []); - const getFormattedTime = (date: Date) => { - return date.toLocaleTimeString("ko-KR", { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, - }); - }; - return ( - <> - {getFormattedTime(time)} +
{time.toLocaleDateString("ko-KR", { - weekday: "short", + weekday: "long", year: "numeric", - month: "2-digit", - day: "2-digit", + month: "long", + day: "numeric", })} - + + {time.toLocaleTimeString("ko-KR", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + })} + +
); }; @@ -81,12 +75,8 @@ interface MenuItemProps { const MemoizedMenuItem = React.memo( ({ item, selected, onClick }: MenuItemProps) => ( - {item.icon} - - - {item.name} - - + {item.icon} + {item.name} ), ); @@ -125,7 +115,10 @@ const Sidebar = () => { const handleMenuClick = useCallback( (menuName: string, path: string) => { setSelectedMenu(menuName); - navigate(path); // 페이지 이동 처리 + navigate(path); + if (window.innerWidth < breakpoints.sm) { + setIsOpen(false); + } }, [navigate], ); @@ -141,7 +134,7 @@ const Sidebar = () => { src={logo} alt="Logo" width="170" - height="59" + height="69" style={{ paddingTop: "10px", cursor: "pointer" }} onClick={handleLogoClick} /> @@ -155,8 +148,8 @@ const Sidebar = () => { src={logo} alt="Logo" width="170" - height="59" - style={{ marginBottom: "15px", cursor: "pointer" }} + height="69" + style={{ marginBottom: "32px", cursor: "pointer" }} onClick={handleLogoClick} /> )} diff --git a/src/components/common/UserInfo/UserInfo.styles.ts b/src/components/common/UserInfo/UserInfo.styles.ts index 3d37542..46a2bd3 100644 --- a/src/components/common/UserInfo/UserInfo.styles.ts +++ b/src/components/common/UserInfo/UserInfo.styles.ts @@ -6,7 +6,6 @@ export const UserInfoWrapper = styled.div` flex-direction: column; justify-content: center; align-items: flex-start; - background-color: #f5f5f5; border-radius: 12px; padding: 10px; width: 76px; diff --git a/src/components/features/CustomCalendar/CustomCalendar.styles.ts b/src/components/features/CustomCalendar/CustomCalendar.styles.ts index e9fff0b..ac20d86 100644 --- a/src/components/features/CustomCalendar/CustomCalendar.styles.ts +++ b/src/components/features/CustomCalendar/CustomCalendar.styles.ts @@ -40,7 +40,7 @@ export const calendarStyles = css` } .fc-timegrid-slot { - height: 1.6rem; + height: 1rem; } .fc-view-harness { @@ -190,3 +190,54 @@ export const eventItemStyles = (status: string, isDragging: boolean) => css` border-left-color: #ef4444; `} `; + +export const dropdownMenuStyles = css` + position: absolute; + top: 100%; + right: 0; + background-color: white; + list-style: none; + padding: 8px 0; + margin: 4px 0 0 0; + border: 1px solid #ddd; + border-radius: 8px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); + z-index: 1000; + min-width: 100px; + animation: fadeIn 0.2s ease-in-out; + + @keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } + } +`; + +export const dropdownItemStyles = css` + padding: 10px 12px; + color: blue; + border-radius: 8px; + font-size: 14px; + cursor: pointer; + text-align: left; + transition: background-color 0.2s; + display: block; + white-space: nowrap; + + &:hover { + padding: 7px 11px; /* hover 시 패딩을 줄여 크기 감소 */ + } +`; + +export const dropdownItemRedStyles = css` + ${dropdownItemStyles} + color: red; + &:hover { + padding: 7px 11px; /* hover 시 패딩을 줄여 크기 감소 */ + } +`; diff --git a/src/components/features/CustomCalendar/CustomCalendar.tsx b/src/components/features/CustomCalendar/CustomCalendar.tsx index 85190de..338da27 100644 --- a/src/components/features/CustomCalendar/CustomCalendar.tsx +++ b/src/components/features/CustomCalendar/CustomCalendar.tsx @@ -7,26 +7,71 @@ import React, { useMemo, } from "react"; import FullCalendar from "@fullcalendar/react"; -import { EventContentArg } from "@fullcalendar/core/index.js"; +import { EventContentArg } from "@fullcalendar/core"; import dayGridPlugin from "@fullcalendar/daygrid"; import timeGridPlugin from "@fullcalendar/timegrid"; import interactionPlugin from "@fullcalendar/interaction"; import koLocale from "@fullcalendar/core/locales/ko"; -import { useQueryClient } from "@tanstack/react-query"; +import styled from "@emotion/styled"; import breakpoints from "@/variants/breakpoints"; import { appContainerStyles, appTitleStyles, calendarStyles, eventItemStyles, + dropdownItemStyles, + dropdownItemRedStyles, + dropdownMenuStyles, } from "./CustomCalendar.styles"; -import useDeletePlan from "@/api/hooks/useDeletePlans"; -import useUpdatePlans from "@/api/hooks/useUpdatePlans"; -import useDeletePlanCard from "@/api/hooks/useDeletePlanCard"; -import useUpdatePlanCard from "@/api/hooks/useUpdatePlanCard"; -import Modal from "./PlanModal"; +import useDeletePlan from "@/api/hooks/useDeletePlan"; +import Modal from "@/components/common/Modal/Modal"; import Button from "@/components/common/Button/Button"; +const ModalContainer = styled.div` + padding: 20px; + background-color: white; + border-radius: 12px; + display: flex; + flex-direction: column; + align-items: center; + gap: 15px; +`; + +const Title = styled.h2` + font-size: 1.8rem; + font-weight: bold; + color: #333; + margin-bottom: 20px; +`; + +const StyledInput = styled.input` + width: 100%; + padding: 12px; + margin-bottom: 10px; + border: 1px solid #ccc; + border-radius: 8px; + font-size: 1rem; + &:focus { + outline: none; + border-color: #6c63ff; + box-shadow: 0 0 0 2px rgba(108, 99, 255, 0.3); + } +`; + +const ToggleContainer = styled.div` + display: flex; + align-items: center; + width: 100%; + justify-content: space-between; + font-size: 1.2rem; + margin-bottom: 10px; +`; + +const ToggleSwitch = styled.input` + width: 20px; + height: 20px; +`; + export interface CalendarEvent { id: string; title: string; @@ -43,9 +88,6 @@ interface CustomCalendarProps { isReadOnly?: boolean; onPlanChange?: (plans: CalendarEvent[]) => void; onDeletePlan?: (planId: string) => void; - isPreviewMode?: boolean; - previewDeviceId?: string; // Preview mode deviceId - previewGroupId?: string; // Preview mode groupId } const VIEW_MODES = { @@ -53,7 +95,6 @@ const VIEW_MODES = { WEEK: "timeGridWeek", }; -// Calculate event status const calculateEventStatus = (event: CalendarEvent) => { const now = new Date(); if (event.complete) return "completed"; @@ -62,65 +103,134 @@ const calculateEventStatus = (event: CalendarEvent) => { return "incomplete"; }; -// Render event content -const renderEventContent = ( - eventInfo: EventContentArg, - handleDelete: (id: string) => void, - handleEdit: (event: CalendarEvent) => void, - isReadOnly: boolean, -) => { +const EventContent = ({ + eventInfo, + handleDelete, + handleEdit, + isReadOnly, +}: { + eventInfo: EventContentArg; + handleDelete: (id: string) => void; + handleEdit: ( + id: string, + title: string, + description: string, + accessibility: boolean | null, + isCompleted: boolean | null, + ) => void; + isReadOnly: boolean; +}) => { const { event, timeText } = eventInfo; const description = event.extendedProps?.description || ""; + const accessibility = event.extendedProps?.accessibility || false; + const isCompleted = event.extendedProps?.isCompleted || false; + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const handleEventClick = (e: React.MouseEvent) => { + e.stopPropagation(); // 이벤트 버블링 방지 + setIsDropdownOpen(!isDropdownOpen); + }; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setIsDropdownOpen(!isDropdownOpen); + } + }; + const handleOptionClick = (option: string) => { + if (option === "edit") { + handleEdit( + event.id, + event.title, + description, + accessibility, + isCompleted, + ); + } else if (option === "delete") { + handleDelete(event.id); + } + setIsDropdownOpen(false); + }; + // 드롭다운 외부 클릭 시 닫힘 처리 + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (!(e.target instanceof Node)) return; + setIsDropdownOpen(false); + }; + if (isDropdownOpen) { + document.addEventListener("click", handleClickOutside); + } + return () => { + document.removeEventListener("click", handleClickOutside); + }; + }, [isDropdownOpen]); return ( -
+
{timeText}
{event.title}
{description}
- {!isReadOnly && ( -
- - -
+ 삭제 + + )}
); }; -// Parse date utility +// 이 함수도 컴포넌트 외부에 위치 +const renderEventContent = ( + eventInfo: EventContentArg, + currentView: string, + handleDelete: (id: string) => void, + handleEdit: ( + id: string, + title: string, + description: string, + accessibility: boolean | null, + isCompleted: boolean | null, + ) => void, + isReadOnly: boolean, +) => { + + if (currentView === "dayGridMonth") { + return
; + } + + return ( + + ); +}; const parseDate = (date: any) => { return typeof date === "string" || typeof date === "number" ? new Date(date) @@ -133,124 +243,71 @@ const CustomCalendar: React.FC = ({ isReadOnly = false, onPlanChange, onDeletePlan, - isPreviewMode = false, - previewDeviceId, - previewGroupId, }) => { const [isMobile, setIsMobile] = useState(window.innerWidth <= breakpoints.sm); + const [currentView, setCurrentView] = useState("timeGridWeek"); const calendarRef = useRef(null); const [currentDate, setCurrentDate] = useState(() => new Date()); - const queryClient = useQueryClient(); - const { mutate: deletePlan } = useDeletePlan(); - const { mutate: updatePlan } = useUpdatePlans(); - const { mutate: deletePlanCard } = useDeletePlanCard(); - const { mutate: updatePlanCard } = useUpdatePlanCard(); - const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [currentEditPlan, setCurrentEditPlan] = useState | null>(null); - // Handle delete const handleDelete = useCallback( (id: string) => { - if (window.confirm("정말로 삭제하시겠습니까?")) { - if (onDeletePlan) { - onDeletePlan(id); - } else if (isPreviewMode && previewDeviceId && previewGroupId) { - // Preview mode delete - deletePlanCard( - { - deviceId: previewDeviceId, - groupId: previewGroupId, - cardId: id, - }, - { - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ["planCards"], - exact: true, - }); - }, - }, - ); - } else { - // Regular delete - deletePlan(Number(id)); - } + if (onDeletePlan) { + onDeletePlan(id); + } else { + deletePlan(Number(id)); } }, - [ - deletePlan, - deletePlanCard, - isPreviewMode, - previewDeviceId, - previewGroupId, - queryClient, - onDeletePlan, - ], + [deletePlan, onDeletePlan], ); - // Handle edit (open modal) - const handleEdit = useCallback((event: CalendarEvent) => { - setCurrentEditPlan(event); + const handleEdit = ( + id: string, + title: string, + description: string, + accessibility: boolean | null, + isCompleted: boolean | null, + ) => { + setCurrentEditPlan({ + id, + title, + description, + accessibility, + complete: isCompleted ?? undefined, + }); setIsEditModalOpen(true); - }, []); + }; - // Handle edit submit (modal form submission) const handleEditSubmit = () => { if (currentEditPlan && currentEditPlan.id) { - const { id, title, description, start, end } = currentEditPlan; - if (isPreviewMode && previewDeviceId && previewGroupId) { - // Preview mode update - updatePlanCard({ - deviceId: previewDeviceId, - groupId: previewGroupId, - cardId: id, - planData: { - title: title!, - description: description!, - startDate: start!.toISOString(), - endDate: end!.toISOString(), - }, - }); - } else if (onPlanChange) { - // Local state update - const updatedPlans = plans.map((plan) => - plan.id === id - ? { ...plan, title: title!, description: description! } - : plan, - ); - onPlanChange(updatedPlans); - } else { - // Regular update - updatePlan({ - planId: Number(id), - planData: { - title: title!, - description: description!, - startDate: start!.toISOString(), - endDate: end!.toISOString(), - accessibility: currentEditPlan.accessibility ?? false, - isCompleted: currentEditPlan.complete ?? false, - }, - }); - } + const updatedPlan = { + title: currentEditPlan.title || "", + description: currentEditPlan.description || "", + accessibility: Boolean(currentEditPlan.accessibility), + isCompleted: Boolean(currentEditPlan.complete), + }; + + // 수정된 플랜 리스트 업데이트 + const updatedPlans = plans.map((plan) => + plan.id === currentEditPlan.id ? { ...plan, ...updatedPlan } : plan, + ); + onPlanChange?.(updatedPlans); setIsEditModalOpen(false); setCurrentEditPlan(null); } }; - // Handle window resize const handleResize = useCallback(() => { - const currentMobile = window.innerWidth <= breakpoints.sm; + const currentMobile = + typeof window !== "undefined" && window.innerWidth <= breakpoints.sm; setIsMobile(currentMobile); const calendarApi = calendarRef.current?.getApi(); - if (calendarApi) { - calendarApi.changeView( - currentMobile ? VIEW_MODES.THREEDAY : VIEW_MODES.WEEK, - ); - } + calendarApi?.changeView( + currentMobile ? "timeGridThreeDay" : "timeGridWeek", + ); }, []); useEffect(() => { @@ -259,7 +316,6 @@ const CustomCalendar: React.FC = ({ return () => window.removeEventListener("resize", handleResize); }, [handleResize]); - // Prepare events for the calendar const parsedEvents = useMemo( () => (plans || []).map((plan) => ({ @@ -271,22 +327,17 @@ const CustomCalendar: React.FC = ({ extendedProps: { description: plan.description, accessibility: plan.accessibility, - complete: plan.complete, + isCompleted: plan.complete, }, })), [plans], ); - // Handle event change (drag and drop) const handleEventChange = useCallback( (info: { event: any }) => { const updatedPlans = plans.map((plan) => plan.id === info.event.id - ? { - ...plan, - start: info.event.start, - end: info.event.end, - } + ? { ...plan, start: info.event.start, end: info.event.end } : plan, ); onPlanChange?.(updatedPlans); @@ -294,9 +345,15 @@ const CustomCalendar: React.FC = ({ [onPlanChange, plans], ); + // useCallback으로 메모이제이션 + const eventContent = useCallback( + (eventInfo: EventContentArg) => + renderEventContent(eventInfo, currentView, handleDelete, handleEdit, isReadOnly), + [handleDelete, handleEdit, isReadOnly], + ); return (
-

{calendarOwner || "My Calendar"}

+ {calendarOwner &&

{calendarOwner}

}
= ({ type: "timeGrid", duration: { days: 3 }, }, + dayGridMonth: { + type: "dayGridMonth", + dayHeaderFormat: { weekday: "short" }, + }, }} initialView={isMobile ? VIEW_MODES.THREEDAY : VIEW_MODES.WEEK} initialDate={currentDate} headerToolbar={{ left: "title", center: "", - right: "prev,next,today", + right: + "prev,next,today dayGridMonth,timeGridWeek,timeGridDay,timeGridThreeDay", }} locale={koLocale} - slotDuration="00:30:00" + slotDuration="00:10:00" slotLabelInterval="01:00:00" slotLabelFormat={{ hour: "2-digit", @@ -334,15 +396,15 @@ const CustomCalendar: React.FC = ({ eventResizableFromStart={!isReadOnly} eventDrop={isReadOnly ? undefined : handleEventChange} eventResize={handleEventChange} - eventContent={(eventInfo) => - renderEventContent(eventInfo, handleDelete, handleEdit, isReadOnly) - } + eventContent={eventContent} selectable={false} selectMirror={false} dayMaxEvents weekends firstDay={1} + timeZone="UTC" events={parsedEvents} + viewDidMount={({ view }) => setCurrentView(view.type)} datesSet={(dateInfo) => setCurrentDate(dateInfo.start)} dayHeaderFormat={{ weekday: "short", @@ -352,29 +414,56 @@ const CustomCalendar: React.FC = ({ }} height={isMobile ? "85%" : "100%"} /> - {/* Edit Modal */} {isEditModalOpen && currentEditPlan && ( setIsEditModalOpen(false)}> -

플랜 수정

- - setCurrentEditPlan((prev) => - prev ? { ...prev, title: e.target.value } : prev, - ) - } - /> - - setCurrentEditPlan((prev) => - prev ? { ...prev, description: e.target.value } : prev, - ) - } - /> - + + 플랜 수정 + + setCurrentEditPlan((prev) => + prev ? { ...prev, title: e.target.value } : prev, + ) + } + /> + + setCurrentEditPlan((prev) => + prev ? { ...prev, description: e.target.value } : prev, + ) + } + /> + + 공개 여부 + + setCurrentEditPlan((prev) => ({ + ...prev, + accessibility: e.target.checked, + })) + } + /> + + + 완료 여부 + + setCurrentEditPlan((prev) => ({ + ...prev, + complete: e.target.checked, + })) + } + /> + + +
)}
diff --git a/src/components/features/CustomCalendar/PlanModal.tsx b/src/components/features/CustomCalendar/PlanModal.tsx deleted file mode 100644 index f8f7fae..0000000 --- a/src/components/features/CustomCalendar/PlanModal.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import React, { ReactNode } from "react"; -import styled from "@emotion/styled"; - -interface ModalProps { - onClose: () => void; - children: ReactNode; -} - -const Overlay = styled.div` - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(0, 0, 0, 0.5); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; -`; - -const ModalContainer = styled.div` - background: white; - padding: 20px; - border-radius: 8px; - width: 90%; - max-width: 500px; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); -`; - -const ModalHeader = styled.div` - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 16px; -`; - -const CloseButton = styled.button` - background: none; - border: none; - font-size: 1.5rem; - cursor: pointer; - color: #333; -`; - -const ModalTitle = styled.h2` - font-size: 1.25rem; - font-weight: bold; -`; - -const ModalContent = styled.div` - margin-top: 10px; - display: flex; - flex-direction: column; - gap: 10px; -`; - -const Modal: React.FC = ({ onClose, children }) => { - return ( - - e.stopPropagation()}> - - 플랜 수정 - × - - {children} - - - ); -}; - -export default Modal; diff --git a/src/components/features/DatePicker/DatePicker.css b/src/components/features/DatePicker/DatePicker.css new file mode 100644 index 0000000..7711efd --- /dev/null +++ b/src/components/features/DatePicker/DatePicker.css @@ -0,0 +1,43 @@ +.custom-datepicker-wrapper { + position: relative; + z-index: 100; +} + +.custom-calendar { + border: 1px solid #ddd !important; + font-family: -apple-system, system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; +} + +/* 시간 선택 컨테이너 스타일 */ +.react-datepicker__time-container { + border-left: 1px solid #ddd !important; + width: 100px !important; + display: block !important; +} + +.react-datepicker { + display: flex !important; +} + +.react-datepicker__time-box { + width: 100px !important; + margin: 0 !important; +} + +/* 시간 리스트 스타일 */ +.react-datepicker__time-list { + height: 200px !important; + overflow-y: scroll !important; + width: 100% !important; +} + +.react-datepicker__time-list-item { + padding: 5px 10px !important; + height: auto !important; +} + +/* 선택된 시간 스타일 */ +.react-datepicker__time-list-item--selected { + background-color: #216ba5 !important; + color: white !important; +} \ No newline at end of file diff --git a/src/components/features/DatePicker/DatePicker.tsx b/src/components/features/DatePicker/DatePicker.tsx new file mode 100644 index 0000000..3577c96 --- /dev/null +++ b/src/components/features/DatePicker/DatePicker.tsx @@ -0,0 +1,180 @@ +import { forwardRef, InputHTMLAttributes } from "react"; +import styled from "@emotion/styled"; +import DatePicker from "react-datepicker"; +import { Global, css } from "@emotion/react"; +import "react-datepicker/dist/react-datepicker.css"; + +// StyledInput 스타일 정의 +const StyledInput = styled.input` + width: 100%; + padding: 12px; + margin-bottom: 10px; + border: 1px solid #ccc; + border-radius: 8px; + font-size: 1rem; + &:focus { + outline: none; + border-color: #6c63ff; + box-shadow: 0 0 0 2px rgba(108, 99, 255, 0.3); + } +`; + +// 커스텀 입력 컴포넌트 타입 정의 +interface CustomDateInputProps extends InputHTMLAttributes { + onClick?: () => void; + value?: string; +} + +// DatePicker의 커스텀 입력 컴포넌트 +const CustomDateInput = forwardRef( + ({ value, onClick, placeholder }, ref) => ( + + ), +); + +interface ReactDatePickerProps { + placeholderText: string; + onDateChange: (date: Date | null) => void; + selectedDate?: Date | null; + showTimeSelect?: boolean; // 시간 선택 기능 + dateFormat?: string; // 날짜 포맷 설정 +} + +const ReactDatePicker = ({ + placeholderText, + onDateChange, + selectedDate, + showTimeSelect = false, + dateFormat = "yyyy/MM/dd", +}: ReactDatePickerProps) => { + const handleDateChange = (date: Date | null) => { + if (date) { + const utcDate = new Date( + date.getTime() - date.getTimezoneOffset() * 60000, + ); + onDateChange(utcDate); + } else { + onDateChange(null); + } + }; + + return ( + <> + + } + /> + + ); +}; + +export default ReactDatePicker; diff --git a/src/components/features/Introduce/Introduce.tsx b/src/components/features/Introduce/Introduce.tsx index 14ff031..60fb2a1 100644 --- a/src/components/features/Introduce/Introduce.tsx +++ b/src/components/features/Introduce/Introduce.tsx @@ -6,6 +6,7 @@ import effectSVG from "@/assets/effect.svg"; import Button from "@/components/common/Button/Button"; import breakpoints from "@/variants/breakpoints"; import kakao_symbol from "@/assets/kakao_symbol.svg"; +import RouterPath from "@/router/RouterPath"; const LandingContainer = styled.div` max-width: 1280px; @@ -130,11 +131,13 @@ const Introduce = () => { const navigate = useNavigate(); const handleStartClick = () => { - navigate("/plan/preview"); + navigate(RouterPath.PREVIEW_PLAN); }; + const loginUrl = import.meta.env.VITE_LOGIN_URL; + const handleLoginClick = () => { - navigate("/login"); + window.location.href = loginUrl; }; return ( diff --git a/src/components/features/Layout/Layout.tsx b/src/components/features/Layout/Layout.tsx index d648a62..d98660e 100644 --- a/src/components/features/Layout/Layout.tsx +++ b/src/components/features/Layout/Layout.tsx @@ -4,10 +4,10 @@ import Sidebar from "@/components/common/Sidebar/Sidebar"; import breakpoints from "@/variants/breakpoints"; const Wrapper = styled.div` - flex-direction: row; /* 모바일 이상에서는 가로 정렬 */ - + flex-direction: row; + display: flex; + width: 100%; ${breakpoints.mobile} { - /* 기본값은 세로 정렬 (모바일) */ flex-direction: column; display: flex; width: 100%; @@ -17,8 +17,9 @@ const Wrapper = styled.div` const ContentWrapper = styled.div` padding-left: 225px; - padding-top: 60px; - + box-sizing: border-box; + width: 100%; + overflow-x: hidden; ${breakpoints.mobile} { flex-grow: 1; padding: 60px 20px; diff --git a/src/components/features/MicrophoneButton/MicrophoneButton.styles.ts b/src/components/features/MicrophoneButton/MicrophoneButton.styles.ts index c14a0bd..263fa5b 100644 --- a/src/components/features/MicrophoneButton/MicrophoneButton.styles.ts +++ b/src/components/features/MicrophoneButton/MicrophoneButton.styles.ts @@ -1,63 +1,61 @@ +// src/components/features/MicrophoneButton/MicrophoneButton.styles.ts import styled from "@emotion/styled"; import { motion } from "framer-motion"; import breakpoints from "@/variants/breakpoints"; -export const ButtonContainer = styled(motion.button)({ - border: "none", - background: "none", - cursor: "pointer", - position: "relative", - overflow: "visible", - display: "flex", - justifyContent: "center", - alignItems: "center", - outline: "none", - padding: 0, - - "&:focus": { - outline: "none", - }, - - // 반응형 설정 - width: "58px", - height: "58px", - - [breakpoints.tablet]: { - width: "50px", - height: "50px", - }, -}); - -export const Circle = styled(motion.ellipse)({ - fill: "#39a7f7", - filter: "drop-shadow(0px 4px 6px rgba(0, 0, 0, 0.2))", -}); - -export const MicrophoneIcon = styled(motion.g)({ - fill: "white", -}); - -export const WaveContainer = styled(motion.div)({ - position: "absolute", - top: 0, - left: 0, - width: "100%", - height: "100%", - display: "flex", - justifyContent: "space-around", - alignItems: "center", -}); - -export const Wave = styled(motion.div)({ - backgroundColor: "white", - borderRadius: "5.625px", - - // 반응형 설정 - width: "4px", - height: "4px", - - [breakpoints.tablet]: { - width: "6px", - height: "6px", - }, -}); +export const ButtonContainer = styled(motion.button)` + border: none; + background: none; + cursor: pointer; + position: relative; + overflow: visible; + display: flex; + justify-content: center; + align-items: center; + padding: 0; + outline: none; + + width: 58px; + height: 58px; + + @media (max-width: ${breakpoints.sm}px) { + width: 50px; + height: 50px; + } + + &:focus { + outline: none; + } +`; + +export const Circle = styled(motion.ellipse)` + fill: #39a7f7; + filter: drop-shadow(0px 4px 6px rgba(0, 0, 0, 0.2)); +`; + +export const MicrophoneIcon = styled(motion.g)` + fill: white; +`; + +export const WaveContainer = styled(motion.div)` + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + justify-content: space-around; + align-items: center; +`; + +export const Wave = styled(motion.div)` + background-color: white; + border-radius: 5px; + width: 4px; + height: 8px; + + @media (max-width: ${breakpoints.sm}px) { + width: 3px; + height: 6px; + } +`; diff --git a/src/components/features/MicrophoneButton/MicrophoneButton.tsx b/src/components/features/MicrophoneButton/MicrophoneButton.tsx index 932c50a..13a98dc 100644 --- a/src/components/features/MicrophoneButton/MicrophoneButton.tsx +++ b/src/components/features/MicrophoneButton/MicrophoneButton.tsx @@ -1,3 +1,5 @@ +// src/components/features/MicrophoneButton/MicrophoneButton.tsx +import React from "react"; import { AnimatePresence } from "framer-motion"; import { ButtonContainer, @@ -8,9 +10,9 @@ import { } from "./MicrophoneButton.styles"; export interface MicrophoneButtonProps { - onStartClick?: () => void; // 녹음 시작 콜백 - onStopClick?: () => void; // 녹음 중지 콜백 - isRecording: boolean; // 추가된 부분 + onStartClick?: () => void; + onStopClick?: () => void; + isRecording: boolean; } const MicrophoneButton: React.FC = ({ @@ -35,6 +37,7 @@ const MicrophoneButton: React.FC = ({ exit={{ opacity: 0 }} transition={{ duration: 0.2 }} > + {/* 마이크 아이콘 경로 */} diff --git a/src/components/features/ProtectedRoute/ProtectedRoute.tsx b/src/components/features/ProtectedRoute/ProtectedRoute.tsx new file mode 100644 index 0000000..9dbcfba --- /dev/null +++ b/src/components/features/ProtectedRoute/ProtectedRoute.tsx @@ -0,0 +1,9 @@ +import { Outlet } from "react-router-dom"; +import useAuth from "@/hooks/useAuth"; + +const ProtectedRoute = () => { + const { authState } = useAuth(); + return authState.isAuthenticated ? : null; +}; + +export default ProtectedRoute; diff --git a/src/context/LoginModalContext.tsx b/src/context/LoginModalContext.tsx new file mode 100644 index 0000000..f7f440d --- /dev/null +++ b/src/context/LoginModalContext.tsx @@ -0,0 +1,34 @@ +import React, { createContext, useContext, useState, ReactNode } from "react"; + +interface ModalContextType { + isLoginModalOpen: boolean; + openLoginModal: () => void; + closeLoginModal: () => void; +} + +const ModalContext = createContext(undefined); + +export const ModalProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + const [isLoginModalOpen, setLoginModalOpen] = useState(false); + + const openLoginModal = () => setLoginModalOpen(true); + const closeLoginModal = () => setLoginModalOpen(false); + + return ( + + {children} + + ); +}; + +export const useModal = () => { + const context = useContext(ModalContext); + if (!context) { + throw new Error("useModal must be used within a ModalProvider"); + } + return context; +}; diff --git a/src/index.css b/src/index.css index 01bd549..bc1cb08 100644 --- a/src/index.css +++ b/src/index.css @@ -18,13 +18,14 @@ html, body { margin: 0; padding: 0; - width: 100vw; - height: 100vh; + width: 100%; + height: 100%; justify-content: center; align-items: center; position: relative; top: 0; left: 0; box-sizing: border-box; -} - + font-family: 'Noto Sans KR', 'Roboto', sans-serif; + +} \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx index af59181..ad55e7a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -19,13 +19,14 @@ import "./index.css"; // }); if ("serviceWorker" in navigator) { - navigator.serviceWorker.getRegistrations().then((registrations) => { - registrations.forEach((registration) => { - if (registration.active && registration.scope.includes("mock")) { - registration.unregister(); // 서비스 워커 해제 - } + navigator.serviceWorker + .register("/firebase-messaging-sw.js") + .then((registration) => { + console.log("Service Worker 등록 성공:", registration); + }) + .catch((error) => { + console.error("Service Worker 등록 실패:", error); }); - }); } ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/src/pages/Friend/FriendDetailPage.tsx b/src/pages/Friend/FriendDetailPage.tsx index fb4989c..3caef98 100644 --- a/src/pages/Friend/FriendDetailPage.tsx +++ b/src/pages/Friend/FriendDetailPage.tsx @@ -1,3 +1,4 @@ +// src/pages/Friend/FriendDetailPage.tsx import { useState } from "react"; import styled from "@emotion/styled"; import { useLocation, useParams } from "react-router-dom"; @@ -13,123 +14,131 @@ import { import { useGetFriendPlans } from "@/api/hooks/useGetPlans"; const PageContainer = styled.div` - width: 100%; - margin: 0 auto; - padding: 16px; + display: flex; + min-height: 100vh; + background-color: #ffffff; +`; + +const ContentWrapper = styled.main` + flex-grow: 1; + padding: 32px; + overflow: auto; + box-sizing: border-box; +`; + +const Heading = styled.h1` + font-size: 24px; + font-weight: 600; + margin-bottom: 24px; + color: #2d3748; +`; + +const CalendarWrapper = styled.div` + margin-bottom: 32px; `; const CommentSection = styled.div` - display: flex; - flex-direction: column; + padding: 24px; + border-radius: 8px; `; const CommentInput = styled.div` display: flex; align-items: center; - gap: 24px; - padding: 8px; + margin-bottom: 24px; + align-items: flex-start; + gap: 10px; + background-color: #ffffff; + padding: 16px 0px 16px 16px; `; const InputWrapper = styled.div` - flex: 1; + flex-grow: 1; display: flex; align-items: center; - padding: 10.4px 14.4px; - border-radius: 12.8px; - border: 1px solid black; + padding: 10px 14px; + border-radius: 12px; + border: 1px solid #cbd5e0; + background-color: #ffffff; `; const Input = styled.input` flex: 1; border: none; outline: none; - font-size: 15.3px; - font-family: "Inter", sans-serif; - font-weight: 700; + font-size: 15px; color: #464646; &::placeholder { color: rgba(70, 70, 70, 0.5); } `; -const Divider = styled.hr` +const IconButton = styled.button` + background: none; border: none; - height: 1.6px; - background-color: #eeeeee; - margin: 8px 0; + cursor: pointer; + color: #39a7f7; + display: flex; + align-items: center; + padding: 4px; + margin-left: 2px; + &:disabled { + cursor: not-allowed; + opacity: 0.6; + } `; -const CommentItem = styled.div` +const CommentList = styled.div` display: flex; flex-direction: column; - padding: 8px; + gap: 16px; `; -const CommentContent = styled.div` +const CommentItem = styled.div` display: flex; align-items: flex-start; - gap: 24px; -`; - -const CommentBubble = styled.div` - background-color: #d9d9d9; - border-radius: 12.8px; - padding: 8px 16.8px; - display: flex; - flex-direction: column; - gap: 8px; + gap: 16px; + background-color: #ffffff; + padding: 16px; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); `; -const CommentBox = styled.div` - border-radius: 12.8px; +const CommentContent = styled.div` + flex: 1; display: flex; flex-direction: column; gap: 8px; `; const CommentAuthor = styled.div` - color: black; - font-size: 15.3px; - font-family: "Inter", sans-serif; - font-weight: 700; + font-size: 16px; + font-weight: 600; + color: #2d3748; `; const CommentText = styled.div` + font-size: 15px; color: #464646; - font-size: 15.3px; - font-family: "Inter", sans-serif; - font-weight: 700; + margin-top: 4px; `; const CommentDate = styled.div` - display: flex; - align-items: center; - color: rgba(55.95, 55.95, 55.95, 0.7); - font-size: 15.3px; - font-family: "Inter", sans-serif; - font-weight: 700; - margin-top: 4px; - padding-left: 12px; + font-size: 14px; + color: #718096; `; -const ActionButtons = styled.div` +const CommentDateWrapper = styled.div` display: flex; align-items: center; - margin-left: 3px; + justify-content: space-between; + margin-top: 8px; `; -const IconButton = styled.button` - background: none; - border: none; - cursor: pointer; - padding: 4px; - color: rgba(55.95, 55.95, 55.95, 0.7); - font-size: 10px; +const ActionButtons = styled.div` display: flex; - align-items: center; - &:hover { - color: #000; - } + margin-right: auto; + font-size: 9px; `; export default function FriendDetailPage() { @@ -176,7 +185,7 @@ export default function FriendDetailPage() { { onSuccess: () => { alert("수정이 완료되었습니다."); - setEditingCommentId(null); // 수정 후 수정 모드 종료 + setEditingCommentId(null); setEditContent(""); }, }, @@ -201,98 +210,101 @@ export default function FriendDetailPage() { return ( - - - - - - setNewComment(e.target.value)} - placeholder="댓글을 입력하세요." - aria-label="댓글 입력" - onKeyPress={(e) => { - if (e.key === "Enter") handleSubmitComment(); - }} - disabled={createCommentMutation.isPending} - /> - - - - - - - {comments.map((comment) => ( - - - - - {editingCommentId === comment.id ? ( - - setEditContent(e.target.value)} - onKeyPress={(e) => { - if (e.key === "Enter") handleUpdateComment(comment.id); - }} - disabled={updateCommentMutation.isPending} - /> - handleUpdateComment(comment.id)} - disabled={updateCommentMutation.isPending} - > - - - - ) : ( - <> - - {comment.writerNickname} + + {friendName ? `${friendName}님의 계획표` : "계획표"} + + + + 댓글 + + + + + setNewComment(e.target.value)} + placeholder="댓글을 입력하세요." + aria-label="댓글 입력" + onKeyPress={(e) => { + if (e.key === "Enter") handleSubmitComment(); + }} + disabled={createCommentMutation.isPending} + /> + + + + + + + {comments.map((comment) => ( + + + + {comment.writerNickname} + {editingCommentId === comment.id ? ( + + setEditContent(e.target.value)} + onKeyPress={(e) => { + if (e.key === "Enter") + handleUpdateComment(comment.id); + }} + disabled={updateCommentMutation.isPending} + /> + handleUpdateComment(comment.id)} + disabled={updateCommentMutation.isPending} + > + + + + ) : ( + <> {comment.content} - - - {formatDate(comment.createdAt)} - {comment.writerId === userId && ( - - { - setEditingCommentId(comment.id); - setEditContent(comment.content); - }} - disabled={ - updateCommentMutation.isPending || - deleteCommentMutation.isPending - } - > - - - handleDeleteComment(comment.id)} - disabled={ - updateCommentMutation.isPending || - deleteCommentMutation.isPending - } - > - - - - )} - - - )} - - - - ))} - + + + {formatDate(comment.createdAt)} + + {comment.writerId === userId && ( + + { + setEditingCommentId(comment.id); + setEditContent(comment.content); + }} + disabled={ + updateCommentMutation.isPending || + deleteCommentMutation.isPending + } + > + + + handleDeleteComment(comment.id)} + disabled={ + updateCommentMutation.isPending || + deleteCommentMutation.isPending + } + > + + + + )} + + + )} + + + ))} + + + ); } diff --git a/src/pages/Friend/FriendPage.tsx b/src/pages/Friend/FriendPage.tsx index 51fd87a..0037e7e 100644 --- a/src/pages/Friend/FriendPage.tsx +++ b/src/pages/Friend/FriendPage.tsx @@ -1,9 +1,9 @@ -/** @jsxImportSource @emotion/react */ import { useState, useMemo } from "react"; -import { css } from "@emotion/react"; +import styled from "@emotion/styled"; import { useNavigate } from "react-router-dom"; import { Search } from "@mui/icons-material"; -import List from "@/components/common/List/List"; +import ProfileImage from "@/components/common/ProfileImage/ProfileImage"; +import UserInfo from "@/components/common/UserInfo/UserInfo"; import { useGetFriends, useGetReceivedRequests, @@ -17,7 +17,6 @@ import { useCancelFriendRequest, } from "@/api/hooks/useFriendRequest"; import useDeleteFriend from "@/api/hooks/useDeleteFriend"; -import breakpoints from "@/variants/breakpoints"; import { Friend, SentRequest, @@ -26,121 +25,341 @@ import { } from "@/types/types"; import Button from "@/components/common/Button/Button"; import useUserData from "@/api/hooks/useUserData"; +import breakpoints from "@/variants/breakpoints"; -// Styles -const pageStyles = css` +// Styled Components +const PageContainer = styled.div` width: 100%; max-width: 1200px; - height: 100vh; - padding: 10px 45px; + padding: 32px; display: flex; flex-direction: column; align-items: center; - font-family: "Inter", sans-serif; box-sizing: border-box; - @media (max-width: ${breakpoints.sm}px) { - padding-top: 80px; - } + overflow-x: hidden; + background-color: #ffffff; `; -const searchBarStyles = css` +const Heading = styled.h1` + font-size: 24px; + font-weight: 600; + margin-bottom: 24px; + color: #2d3748; + margin-right: auto; +`; + +const TabsContainer = styled.div` display: flex; + justify-content: flex-end; + gap: 28px; + width: 100%; + margin-bottom: 20px; + box-sizing: border-box; + flex-wrap: wrap; +`; + +const Tab = styled.div<{ active: boolean }>` + font-size: 15px; + font-weight: ${(props) => (props.active ? 600 : 400)}; + color: ${(props) => (props.active ? "#39a7f7" : "#9b9b9b")}; + cursor: pointer; + transition: color 0.3s ease; +`; + +const SearchBarWrapper = styled.div` + width: 100%; +`; +const SearchBar = styled.div` + display: flex; + flex: 1; align-items: center; padding: 10px; - background: #f4f4f4; - border-radius: 16px; + border-radius: 8px; margin-bottom: 20px; width: 100%; box-sizing: border-box; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + background-color: #ffffff; `; -const searchIconStyles = css` +const SearchIconStyled = styled(Search)` color: #aab2c8; font-size: 20px; margin-right: 10px; `; -const searchInputStyles = css` +const SearchInput = styled.input` flex: 1; border: none; background: transparent; font-size: 15.28px; font-weight: 700; - color: #aab2c8; + color: #4a5568; outline: none; &::placeholder { color: #aab2c8; } `; -const searchButtonStyles = css` - color: #aab2c8; +const SearchButton = styled.span` + color: #39a7f7; cursor: pointer; font-weight: bold; margin-left: 10px; `; -const tabsStyles = css` - display: flex; - justify-content: flex-end; - gap: 28px; +const FriendListContainer = styled.div` width: 100%; - margin-bottom: 20px; + display: grid; + gap: 16px; box-sizing: border-box; -`; - -const tabStyles = css` - font-size: 15px; - font-weight: 400; - color: #9b9b9b; - cursor: pointer; - transition: color 0.3s ease; + grid-template-columns: 1fr; - &.active { - color: black; - font-weight: 600; + @media (min-width: ${breakpoints.lg}px) { + grid-template-columns: 1fr 1fr; } `; -const friendListStyles = css` - width: 100%; +const FriendItemContainer = styled.div` display: flex; - flex-direction: column; - gap: 10px; + align-items: center; + background-color: #ffffff; + border-radius: 8px; box-sizing: border-box; + padding: 16px; + gap: 16px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + transition: box-shadow 0.2s; + &:hover { + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.15); + } `; -const friendItemStyles = css` - display: flex; - align-items: center; - padding: 10px; - background: #f4f4f4; - border-radius: 16px; - box-sizing: border-box; - width: 100%; - flex-wrap: nowrap; - min-width: 394px; +const ProfileImageWrapper = styled.div` + margin-right: 16px; `; -const buttonContainerStyles = css` +const ButtonContainer = styled.div` display: flex; gap: 10px; margin-left: auto; - margin-right: 20px; `; -// 검색 결과 아이템 컴포넌트 -const SearchResultItem = ({ friend }: { friend: SearchResult }) => { +const EmptyMessage = styled.p` + text-align: center; + color: #999; + font-size: 16px; + margin-top: 20px; +`; + +const FriendPage = () => { + const [activeTab, setActiveTab] = useState("friendList"); + const [searchQuery, setSearchQuery] = useState(""); + const [searchResults, setSearchResults] = useState(null); + const [searched, setSearched] = useState(false); + + // React Query hooks + const { + data: friendList = [], + isLoading: isLoadingFriends, + refetch: refetchFriends, + } = useGetFriends(); + + const { + data: receivedRequests = [], + isLoading: isLoadingReceived, + refetch: refetchReceivedRequests, + } = useGetReceivedRequests(); + + const { + data: sentRequests = [], + isLoading: isLoadingSent, + refetch: refetchSentRequests, + } = useGetSentRequests(); + + const { refetch: fetchFriendByNickname } = + useGetFriendByNickname(searchQuery); + + const handleSearch = async () => { + setSearched(true); + if (searchQuery.trim()) { + const { data } = await fetchFriendByNickname(); + if (data) { + setSearchResults({ + id: data.id, + nickname: data.nickname, + profileImage: data.profileImage, + }); + } else { + setSearchResults(null); + } + } else { + alert("검색어를 입력해주세요."); + } + }; + + const handleSearchInputChange = (e: React.ChangeEvent) => { + setSearchQuery(e.target.value); + setSearched(false); + }; + + const renderedFriendList = useMemo(() => { + if (friendList.length === 0) { + return 친구가 없습니다.; + } + return friendList.map((friend) => ( + + )); + }, [friendList, refetchFriends]); + + const renderedSentRequests = useMemo(() => { + if (sentRequests.length === 0) { + return 보낸 요청이 없습니다.; + } + return sentRequests.map((request) => ( + + )); + }, [ + sentRequests, + refetchFriends, + refetchReceivedRequests, + refetchSentRequests, + ]); + + const renderedReceivedRequests = useMemo(() => { + if (receivedRequests.length === 0) { + return 받은 요청이 없습니다.; + } + return receivedRequests.map((request) => ( + + )); + }, [ + receivedRequests, + refetchFriends, + refetchReceivedRequests, + refetchSentRequests, + ]); + + return ( + + 친구 + + setActiveTab("friendSearch")} + > + 친구 검색 + + setActiveTab("friendList")} + > + 친구 목록 + + setActiveTab("receivedRequests")} + > + 받은 요청 + + setActiveTab("sentRequests")} + > + 보낸 요청 + + + + {activeTab === "friendSearch" && ( + + + + + { + if (e.key === "Enter") handleSearch(); + }} + > + 검색 + + + {searched && !searchResults && ( + 검색 결과가 없습니다. + )} + {searchResults && ( + + )} + + )} + + {activeTab !== "friendSearch" && ( + + {activeTab === "friendList" && + !isLoadingFriends && + renderedFriendList} + {activeTab === "sentRequests" && + !isLoadingSent && + renderedSentRequests} + {activeTab === "receivedRequests" && + !isLoadingReceived && + renderedReceivedRequests} + + )} + + ); +}; + +// Components + +const SearchResultItem = ({ + friend, + refetchSentRequests, +}: { + friend: SearchResult; + refetchSentRequests: () => void; +}) => { const { sendFriendRequest, isLoading } = useFriendRequest(); - const handleFriendRequest = () => { - sendFriendRequest(friend.id); + const handleFriendRequest = async () => { + await sendFriendRequest(friend.id); + refetchSentRequests(); }; return ( -
- -
+ + + + + + -
-
+ + ); }; -// List 아이템을 렌더링하는 컴포넌트 -const FriendItem = ({ friend }: { friend: Friend }) => { +const FriendItem = ({ + friend, + refetchFriends, +}: { + friend: Friend; + refetchFriends: () => void; +}) => { const navigate = useNavigate(); const deleteFriendMutation = useDeleteFriend(friend.userId); const { userData } = useUserData(); + const handleVisitClick = () => { navigate(`/friend/${friend.userId}`, { state: { friendName: friend.nickname, userId: userData.id }, @@ -167,22 +392,30 @@ const FriendItem = ({ friend }: { friend: Friend }) => { const handleDeleteClick = () => { if (window.confirm("정말로 삭제하시겠습니까?")) { - deleteFriendMutation.mutate(); + deleteFriendMutation.mutate(undefined, { + onSuccess: () => { + refetchFriends(); + alert("친구를 삭제했습니다."); + }, + }); } }; return ( -
- -
+ + + + + + -
-
+ + ); }; @@ -190,14 +423,20 @@ function isSentRequest( request: SentRequest | ReceivedRequest, ): request is SentRequest { return (request as SentRequest).receiverName !== undefined; -} // 타입가드 +} const RequestItem = ({ request, type, + refetchFriends, + refetchReceivedRequests, + refetchSentRequests, }: { request: SentRequest | ReceivedRequest; type: "sent" | "received"; + refetchFriends: () => void; + refetchReceivedRequests: () => void; + refetchSentRequests: () => void; }) => { const acceptFriendRequestMutation = useAcceptFriendRequest(request.id); const rejectFriendRequestMutation = useRejectFriendRequest(request.id); @@ -205,38 +444,57 @@ const RequestItem = ({ const handleAcceptClick = () => { if (window.confirm("이 친구 요청을 수락하시겠습니까?")) { - acceptFriendRequestMutation.mutate(); + acceptFriendRequestMutation.mutate(undefined, { + onSuccess: () => { + refetchFriends(); + refetchReceivedRequests(); + alert("친구 요청을 수락했습니다."); + }, + }); } }; const handleRejectClick = () => { if (window.confirm("이 친구 요청을 거절하시겠습니까?")) { - rejectFriendRequestMutation.mutate(); + rejectFriendRequestMutation.mutate(undefined, { + onSuccess: () => { + refetchReceivedRequests(); + alert("친구 요청을 거절했습니다."); + }, + }); } }; const handleCancelClick = () => { if (window.confirm("이 친구 요청을 취소하시겠습니까?")) { - cancelFriendRequestMutation.mutate(); + cancelFriendRequestMutation.mutate(undefined, { + onSuccess: () => { + refetchSentRequests(); + }, + }); } }; return ( -
- + + + + -
+ {type === "sent" ? ( - ) : ( @@ -249,145 +507,9 @@ const RequestItem = ({ )} -
-
+ + ); }; -export default function FriendListPage() { - const [activeTab, setActiveTab] = useState("friendList"); - const [searchQuery, setSearchQuery] = useState(""); - const [searchResults, setSearchResults] = useState(null); - const [searched, setSearched] = useState(false); - - // React Query 훅 사용 - const { data: friendList = [], isLoading: isLoadingFriends } = - useGetFriends(); - const { data: receivedRequests = [], isLoading: isLoadingReceived } = - useGetReceivedRequests(); - const { data: sentRequests = [], isLoading: isLoadingSent } = - useGetSentRequests(); - const { refetch: fetchFriendByNickname } = - useGetFriendByNickname(searchQuery); - - const handleSearch = async () => { - setSearched(true); - if (searchQuery.trim()) { - const { data } = await fetchFriendByNickname(); - if (data) { - setSearchResults({ - id: data.id, - nickname: data.nickname, - profileImage: data.profileImage, - }); - } else { - setSearchResults(null); - } - } else { - alert("검색어를 입력해주세요."); - } - }; - - const handleSearchInputChange = (e: React.ChangeEvent) => { - setSearchQuery(e.target.value); - setSearched(false); - }; - - const renderedFriendList = useMemo( - () => - friendList.map((friend) => ( - - )), - [friendList], - ); - const renderedSentRequests = useMemo( - () => - sentRequests.map((request) => ( - - )), - [sentRequests], - ); - const renderedReceivedRequests = useMemo( - () => - receivedRequests.map((request) => ( - - )), - [receivedRequests], - ); - - return ( -
-
-
setActiveTab("friendSearch")} - role="button" - tabIndex={0} - > - 친구 검색 -
-
setActiveTab("friendList")} - role="button" - tabIndex={0} - > - 친구 목록 -
-
setActiveTab("receivedRequests")} - role="button" - tabIndex={0} - > - 받은 요청 -
-
setActiveTab("sentRequests")} - role="button" - tabIndex={0} - > - 보낸 요청 -
-
-
- {activeTab === "friendSearch" && ( -
-
- - - { - if (e.key === "Enter") handleSearch(); - }} - > - 검색 - -
- {searched && !searchResults &&

검색 결과가 없습니다.

} - {searchResults && } -
- )} - {activeTab === "friendList" && !isLoadingFriends && renderedFriendList} - {activeTab === "sentRequests" && !isLoadingSent && renderedSentRequests} - {activeTab === "receivedRequests" && - !isLoadingReceived && - renderedReceivedRequests} -
-
- ); -} +export default FriendPage; diff --git a/src/pages/LoginModal/LoginModal.tsx b/src/pages/LoginModal/LoginModal.tsx index cd018af..66b0185 100644 --- a/src/pages/LoginModal/LoginModal.tsx +++ b/src/pages/LoginModal/LoginModal.tsx @@ -1,56 +1,65 @@ import styled from "@emotion/styled"; import Button from "@/components/common/Button/Button"; -import NavBar from "@/components/features/Navbar/Navbar"; +import { useModal } from "@/context/LoginModalContext"; -const LoginContainer = styled.div` +const ModalOverlay = styled.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); display: flex; - flex-direction: column; - align-items: center; justify-content: center; - height: calc(100vh - 80px); - padding: 0 20px; - background-color: #f9f9f9; + align-items: center; + z-index: 9999; +`; + +const ModalContent = styled.div` + background: white; + padding: 2rem; + border-radius: 8px; + width: 400px; + text-align: center; `; const Title = styled.h1` - font-size: 2.5rem; + font-size: 1.3rem; font-weight: bold; margin-bottom: 1rem; color: #333; `; const Description = styled.p` - font-size: 1.2rem; + font-size: 1rem; color: #555; margin-bottom: 2rem; `; const LoginModal: React.FC = () => { + const { isLoginModalOpen, closeLoginModal } = useModal(); + const loginUrl = import.meta.env.VITE_LOGIN_URL; + const handleLogin = async () => { try { - window.location.href = - "https://api.splanet.co.kr/oauth2/authorization/kakao"; + window.location.href = loginUrl; } catch (e) { console.error("로그인 에러:", e); } }; - return ( - <> - {/* Navbar */} - - - {/* 로그인 페이지 컨테이너 */} - - 로그인 페이지 - 임시로 만든 로그인 페이지입니다. + if (!isLoginModalOpen) return null; - {/* Login 버튼 */} - - - + + ); }; diff --git a/src/pages/LoginModal/RedirectPage.tsx b/src/pages/LoginModal/RedirectPage.tsx index c3348b3..0f398d0 100644 --- a/src/pages/LoginModal/RedirectPage.tsx +++ b/src/pages/LoginModal/RedirectPage.tsx @@ -1,88 +1,54 @@ -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { useNavigate } from "react-router-dom"; -import RouterPath from "@/router/RouterPath"; -import useSavePreviewPlan from "@/api/hooks/useSavePreviewPlan"; import useAuth from "@/hooks/useAuth"; -import { apiClient } from "@/api/instance"; -import { CalendarEvent } from "@/components/features/CustomCalendar/CustomCalendar"; +import RouterPath from "@/router/RouterPath"; const OAuthRedirectHandler = () => { const navigate = useNavigate(); const { setAuthState, authState } = useAuth(); - const { mutate: savePreviewPlan } = useSavePreviewPlan(); - const [hasSaved, setHasSaved] = useState(false); - // 인증 처리를 위한 useEffect useEffect(() => { - const handleAuth = async () => { + try { const queryParams = new URLSearchParams(window.location.search); const accessToken = queryParams.get("access"); const refreshToken = queryParams.get("refresh"); - // const deviceId = queryParams.get("deviceId"); - - if (!accessToken || !refreshToken) { - navigate(RouterPath.LOGIN); - return; + const deviceId = queryParams.get("deviceId"); + + if (accessToken && refreshToken) { + const cookieOptions = "path=/; Secure; SameSite=Strict;"; + + // 토큰을 쿠키에 저장 + document.cookie = `access_token=${accessToken}; ${cookieOptions}`; + document.cookie = `refresh_token=${refreshToken}; ${cookieOptions}`; + document.cookie = `device_id=${deviceId}; ${cookieOptions}`; + + // 상태 업데이트 + const newAuthState = { + isAuthenticated: true, + }; + setAuthState(newAuthState); + localStorage.setItem("authState", JSON.stringify(newAuthState)); + // axios 인스턴스 헤더에 토큰 추가 + // apiClient.defaults.headers.common.Authorization = `Bearer ${accessToken}`; + } else { + // 토큰이 없으면 메인 페이지로 리다이렉트 + navigate(RouterPath.HOME); } + } catch (error) { + console.error("OAuth 리다이렉트 처리 중 오류 발생:", error); + navigate(RouterPath.HOME); + } + }, [navigate, setAuthState]); - const cookieOptions = "path=/; Secure; SameSite=Strict;"; - document.cookie = `access_token=${accessToken}; ${cookieOptions}`; - document.cookie = `refresh_token=${refreshToken}; ${cookieOptions}`; - - setAuthState({ - isAuthenticated: true, - accessToken, - }); - - apiClient.defaults.headers.common.Authorization = `Bearer ${accessToken}`; - }; - - handleAuth(); - }, []); // 빈 의존성 배열 - - // 플랜 저장을 위한 useEffect + // authState가 업데이트되었을 때 메인 페이지로 리다이렉트 useEffect(() => { - const savePlans = async () => { - if (!authState.isAuthenticated || hasSaved) return; - - const savedPlanData = localStorage.getItem("previewPlanData"); - if (!savedPlanData) { - setHasSaved(true); - navigate(RouterPath.MAIN); - return; - } - - try { - const { selectedPlan, previewDeviceId, previewGroupId } = - JSON.parse(savedPlanData); - - // 모든 플랜을 한 번에 전송 - const planDataList = selectedPlan.map((plan: CalendarEvent) => ({ - title: plan.title, - description: plan.description, - startDate: new Date(plan.start).toISOString(), - endDate: new Date(plan.end).toISOString(), - })); - - await savePreviewPlan({ - deviceId: previewDeviceId, - groupId: previewGroupId, - planDataList, // 전체 플랜 배열 전송 - }); - - localStorage.removeItem("previewPlanData"); - setHasSaved(true); - navigate(RouterPath.MAIN); - } catch (error) { - console.error("플랜 저장 실패:", error); - navigate(RouterPath.MAIN); - } - }; - - savePlans(); - }, [authState.isAuthenticated]); + // console.log("현재 authState:", authState); + if (authState.isAuthenticated) { + navigate(RouterPath.MAIN); + } + }, [authState, navigate]); - return
로그인 처리 중...
; + return
리다이렉트 처리 중...
; }; export default OAuthRedirectHandler; diff --git a/src/pages/Main/MainPage.tsx b/src/pages/Main/MainPage.tsx index 54a3845..d34cb5a 100644 --- a/src/pages/Main/MainPage.tsx +++ b/src/pages/Main/MainPage.tsx @@ -1,266 +1,119 @@ +import { useEffect, useRef } from "react"; import styled from "@emotion/styled"; -import { useState, useEffect } from "react"; -import CustomCalendar from "@/components/features/CustomCalendar/CustomCalendar"; +import { useNavigate, useLocation } from "react-router-dom"; +import CustomCalendar, { + CalendarEvent, +} from "@/components/features/CustomCalendar/CustomCalendar"; import { useGetPlans } from "@/api/hooks/useGetPlans"; -import useCreatePlan from "@/api/hooks/useCreatePlans"; -import CircleButton from "@/components/common/CircleButton/CircleButton"; -import breakpoints from "@/variants/breakpoints"; - -// 캘린더와 버튼을 포함하는 반응형 컨테이너 -const CalendarContainer = styled.div` - position: relative; - width: 100%; - max-width: 1200px; // 최대 너비를 설정해 버튼이 중앙을 유지하도록 합니다. - margin: 0 auto; +import useCreatePlan from "@/api/hooks/useCreatePlan"; +import Button from "@/components/common/Button/Button"; +import RouterPath from "@/router/RouterPath"; +import { requestForToken, setupOnMessageListener } from "@/api/firebaseConfig"; // Import Firebase functions +import { apiClient } from "@/api/instance"; + +const PageContainer = styled.div` + background-color: #ffffff; padding: 20px; `; -// 버튼을 캘린더 컨테이너 내의 상대적인 위치에 두도록 설정 -const ButtonWrapper = styled.div` - position: absolute; - top: 10%; - display: flex; - justify-content: flex-start; - margin-top: 20px; - left: calc(10% + 10px); - z-index: 1; - - ${breakpoints.tablet} { - top: 10%; - left: 20px; - } - - ${breakpoints.mobile} { - top: 10%; - left: 20px; - } -`; - -// 모달 스타일 -const ModalOverlay = styled.div` - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.5); - display: flex; - align-items: center; - justify-content: center; - z-index: 2; -`; - -const ModalContent = styled.div` - background: white; - padding: 20px 30px 20; - width: 500px; // 가로폭 설정 - max-width: 95%; - border-radius: 8px; - box-shadow: 0px 4px 16px rgba(0, 0, 0, 0.2); - display: flex; - flex-direction: column; - gap: 15px; /* 각 입력 필드 사이에 간격 추가 */ -`; - -const Input = styled.input` - width: 100%; - padding: 8px; - margin-top: 10px; - margin-bottom: 10px; - border: 1px solid #ccc; - border-radius: 4px; -`; - -const Textarea = styled.textarea` - width: 99%; - padding: 10px; - margin-top: 5px; - border: 1px solid #ccc; - border-radius: 4px; - resize: vertical; -`; - -const CloseButton = styled.button` - margin-top: 10px; - background-color: #39a7f7; - color: white; - border: none; - padding: 10px 15px; - border-radius: 4px; - cursor: pointer; -`; - -const ToggleWrapper = styled.label` - display: flex; - flex-direction: column; - cursor: pointer; - gap: 10px; -`; - -const ToggleInput = styled.input` - appearance: none; - width: 40px; - height: 20px; - background: #ccc; - border-radius: 20px; - position: relative; - outline: none; - transition: background 0.3s; - - &:checked { - background: #39a7f7; - } - - &:before { - content: ""; - position: absolute; - width: 18px; - height: 18px; - border-radius: 50%; - background: white; - top: 1px; - left: 1px; - transition: transform 0.3s; - } - - &:checked:before { - transform: translateX(20px); - } -`; - -const ToggleLabel = styled.span` - font-size: 16px; - color: #333; -`; - -const MainPage: React.FC = () => { - const { data: plans, isLoading, error } = useGetPlans(); - const [modalOpen, setIsModalOpen] = useState(false); - const [title, setTitle] = useState(""); - const [description, setDescription] = useState(""); - const [startDate, setStartDate] = useState(""); - const [endDate, setEndDate] = useState(""); - const [isAccessible, setIsAccessible] = useState(false); - const [isCompleted, setIsCompleted] = useState(false); +export default function MainPage() { + const location = useLocation(); + const { data: Plans, isLoading, error, refetch } = useGetPlans(); + const savePlanMutation = useCreatePlan(); + const isPlanSaved = useRef(false); + const hasMounted = useRef(false); + const navigate = useNavigate(); - // useCreatePlan 훅 사용 - const createPlanMutation = useCreatePlan(); + useEffect(() => { + if (location.state?.refetchNeeded) { + refetch(); + } + }, [location, refetch]); - const handleToggleChange = - (setter: React.Dispatch>) => () => { - setter((prev) => !prev); + useEffect(() => { + if (hasMounted.current) return; + hasMounted.current = true; + + const savePlans = async () => { + const storedPlans = sessionStorage.getItem("plans"); + if (storedPlans && !isPlanSaved.current) { + const parsedPlans: CalendarEvent[] = JSON.parse(storedPlans).map( + (plan: CalendarEvent) => ({ + ...plan, + start: new Date(plan.start), + end: new Date(plan.end), + }), + ); + + try { + await Promise.all( + parsedPlans.map((plan) => + savePlanMutation.mutateAsync({ + plan: { + title: plan.title, + description: plan.description, + startDate: plan.start.toISOString(), + endDate: plan.end.toISOString(), + accessibility: plan.accessibility ?? true, + isCompleted: plan.complete ?? false, + }, + }), + ), + ); + sessionStorage.removeItem("plans"); + console.log("세션의 플랜이 저장되었습니다."); + isPlanSaved.current = true; + refetch(); + } catch (err) { + console.error("세션의 플랜 저장 실패:", err); + } + } }; - const handleButtonClick = () => { - setIsModalOpen(true); - }; - - // 폼 제출 핸들러 - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - createPlanMutation.mutate({ - title, - description, - startDate, - endDate, - accessibility: isAccessible, - isCompleted, - }); - setIsModalOpen(false); // 폼 제출 후 모달 닫기 - }; + savePlans(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // Notification functionality useEffect(() => { - // 로컬 스토리지 정리 (필요한 경우) - const savedPreviewData = localStorage.getItem("previewPlanData"); - if (savedPreviewData) { - localStorage.removeItem("previewPlanData"); - } + const registerFcmToken = async () => { + const permission = await Notification.requestPermission(); + if (permission === "granted") { + try { + const fcmToken = await requestForToken(); + if (fcmToken) { + await apiClient.post("/api/fcm/register", { token: fcmToken }); + console.log("FCM 토큰이 성공적으로 등록되었습니다."); + } + } catch (err) { + console.error("FCM 토큰 등록 중 오류 발생:", err); + } + } else { + console.log("알림 권한이 거부되었습니다."); + } + }; + + registerFcmToken(); + setupOnMessageListener(); // Set up the listener for foreground messages }, []); - if (isLoading) { - return
Loading...
; // 로딩 상태 처리 - } + const handleModifyClick = () => { + navigate(RouterPath.MAIN_MODIFY, { state: { plans: Plans } }); + }; - if (error) { - return
Error: {error.message}
; // 에러 처리 - } + if (isLoading) return

로딩 중...

; + if (error) return

데이터를 불러오지 못했습니다. 오류: {error.message}

; return ( - - - + - - - - {modalOpen && ( - - -

새 일정 추가

-
- 제목 - setTitle(e.target.value)} - /> - - 설명 -