diff --git a/.eslintrc.js b/.eslintrc.js index cb854af6..a373fb94 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -30,6 +30,8 @@ module.exports = { 'react/jsx-props-no-spreading': 'off', 'no-use-before-define': 'off', '@typescript-eslint/no-use-before-define': ['off'], + "react/require-default-props": 'off', + "react/self-closing-comp": 'off', }, settings: { react: { diff --git a/.vscode/settings.json b/.vscode/settings.json index d5bfa7d4..a9d3f305 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -64,5 +64,8 @@ } ] } - } + }, + "cSpell.words": [ + "clsx" + ] } diff --git a/next.config.mjs b/next.config.mjs index d5456a15..49ba09f8 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,6 +1,23 @@ /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, + images: { + domains: ['sprint-fe-project.s3.ap-northeast-2.amazonaws.com', 'localhost'], + remotePatterns: [ + { + protocol: 'https', + hostname: 'via.placeholder.com', + port: '', + pathname: '/**', + }, + ], + }, + rewrites: async () => [ + { + source: '/api/proxy/:path*', + destination: 'https://sprint-fe-project.s3.ap-northeast-2.amazonaws.com/:path*', + }, + ], }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 92e0a253..ce9380b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@lukemorales/query-key-factory": "^1.3.4", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-avatar": "^1.1.0", + "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-radio-group": "^1.2.0", @@ -23,21 +24,27 @@ "axios": "^1.7.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "date-fns": "^3.6.0", + "formik": "^2.4.6", "lucide-react": "^0.402.0", "next": "14.2.4", "qs": "^6.12.2", "react": "^18", "react-dom": "^18", "react-hook-form": "^7.52.1", + "react-toastify": "^10.0.5", + "sharp": "^0.33.4", "tailwind-merge": "^2.4.0", "tailwindcss-animate": "^1.0.7", + "yup": "^1.4.0", "zod": "^3.23.8" }, "devDependencies": { "@tanstack/eslint-plugin-query": "^5.50.0", - "@types/node": "^20", + "@types/lodash": "^4.17.7", + "@types/node": "^20.14.10", "@types/qs": "^6.9.15", - "@types/react": "^18", + "@types/react": "^18.3.3", "@types/react-dom": "^18", "@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/parser": "^7.2.0", @@ -56,8 +63,8 @@ "postcss": "8.4.39", "prettier": "^3.2.5", "prettier-eslint": "^16.3.0", - "tailwindcss": "3.4.4", - "typescript": "^5" + "tailwindcss": "^3.4.4", + "typescript": "^5.5.3" }, "engines": { "node": ">=20.15.0" @@ -74,6 +81,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@emnapi/runtime": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.2.0.tgz", + "integrity": "sha512-bV21/9LQmcQeCPEg3BDFtvwL6cwiTMksYNWQQ4KOxCZikEGalWtenoZ0wCiukJINlGCIi2KXx01g4FoH/LxpzQ==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -251,6 +267,437 @@ "deprecated": "Use @eslint/object-schema instead", "dev": true }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.4.tgz", + "integrity": "sha512-p0suNqXufJs9t3RqLBO6vvrgr5OhgbWp76s5gTRvdmxmuv9E1rcaqGUsl3l4mKVmXPkTkTErXediAui4x+8PSA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.2" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.4.tgz", + "integrity": "sha512-0l7yRObwtTi82Z6ebVI2PnHT8EB2NxBgpK2MiKJZJ7cz32R4lxd001ecMhzzsZig3Yv9oclvqqdV93jo9hy+Dw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.2" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-tcK/41Rq8IKlSaKRCCAuuY3lDJjQnYIW1UXU1kxcEKrfL8WR7N6+rzNoOxoQRJWTAECuKwgAHnPvqXGN8XfkHA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "macos": ">=11", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.2.tgz", + "integrity": "sha512-Ofw+7oaWa0HiiMiKWqqaZbaYV3/UGL2wAPeLuJTx+9cXpCRdvQhCLG0IH8YGwM0yGWGLpsF4Su9vM1o6aer+Fw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "macos": ">=10.13", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.2.tgz", + "integrity": "sha512-iLWCvrKgeFoglQxdEwzu1eQV04o8YeYGFXtfWU26Zr2wWT3q3MTzC+QTCO3ZQfWd3doKHT4Pm2kRmLbupT+sZw==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.2.tgz", + "integrity": "sha512-x7kCt3N00ofFmmkkdshwj3vGPCnmiDh7Gwnd4nUwZln2YjqPxV1NlTyZOvoDWdKQVDL911487HOueBvrpflagw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.2.tgz", + "integrity": "sha512-cmhQ1J4qVhfmS6szYW7RT+gLJq9dH2i4maq+qyXayUSn9/3iY2ZeWpbAgSpSVbV2E1JUL2Gg7pwnYQ1h8rQIog==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.2.tgz", + "integrity": "sha512-E441q4Qdb+7yuyiADVi5J+44x8ctlrqn8XgkDTwr4qPJzWkaHwD489iZ4nGDgcuya4iMN3ULV6NwbhRZJ9Z7SQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.2.tgz", + "integrity": "sha512-3CAkndNpYUrlDqkCM5qhksfE+qSIREVpyoeHIU6jd48SJZViAmznoQQLAv4hVXF7xyUB9zf+G++e2v1ABjCbEQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.2.tgz", + "integrity": "sha512-VI94Q6khIHqHWNOh6LLdm9s2Ry4zdjWJwH56WoiJU7NTeDwyApdZZ8c+SADC8OH98KWNQXnE01UdJ9CSfZvwZw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.4.tgz", + "integrity": "sha512-RUgBD1c0+gCYZGCCe6mMdTiOFS0Zc/XrN0fYd6hISIKcDUbAW5NtSQW9g/powkrXYm6Vzwd6y+fqmExDuCdHNQ==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.2" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.4.tgz", + "integrity": "sha512-2800clwVg1ZQtxwSoTlHvtm9ObgAax7V6MTAB/hDT945Tfyy3hVkmiHpeLPCKYqYR1Gcmv1uDZ3a4OFwkdBL7Q==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.2" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.4.tgz", + "integrity": "sha512-h3RAL3siQoyzSoH36tUeS0PDmb5wINKGYzcLB5C6DIiAn2F3udeFAum+gj8IbA/82+8RGCTn7XW8WTFnqag4tQ==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.31", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.2" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.4.tgz", + "integrity": "sha512-GoR++s0XW9DGVi8SUGQ/U4AeIzLdNjHka6jidVwapQ/JebGVQIpi52OdyxCNVRE++n1FCLzjDovJNozif7w/Aw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.2" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.4.tgz", + "integrity": "sha512-nhr1yC3BlVrKDTl6cO12gTpXMl4ITBUZieehFvMntlCXFzH2bvKG76tBL2Y/OqhupZt81pR7R+Q5YhJxW0rGgQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.2" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.4.tgz", + "integrity": "sha512-uCPTku0zwqDmZEOi4ILyGdmW76tH7dm8kKlOIV1XC5cLyJ71ENAAqarOHQh0RLfpIpbV5KOpXzdU6XkJtS0daw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.2" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.4.tgz", + "integrity": "sha512-Bmmauh4sXUsUqkleQahpdNXKvo+wa1V9KhT2pDA4VJGKwnKMJXiSTGphn0gnJrlooda0QxCtXc6RX1XAU6hMnQ==", + "cpu": [ + "wasm32" + ], + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.1.1" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.4.tgz", + "integrity": "sha512-99SJ91XzUhYHbx7uhK3+9Lf7+LjwMGQZMDlO/E/YVJ7Nc3lyDFZPGhjwiYdctoH2BOzW9+TnfqcaMKt0jHLdqw==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.4.tgz", + "integrity": "sha512-3QLocdTRVIrFNye5YocZl+KKpYKP+fksi1QhmOArgx7GyhIbQp/WrJRu176jm8IxromS7RIkzMiMINVdBtC8Aw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1475,16 +1922,31 @@ "react": "^18 || ^19" } }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz", + "integrity": "sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==", + "dependencies": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.17.7", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz", + "integrity": "sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==", + "dev": true + }, "node_modules/@types/node": { - "version": "20.14.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.9.tgz", - "integrity": "sha512-06OCtnTXtWOZBJlRApleWndH4JsRVs1pDCc8dLSQp+7PpUpX3ePdHyeNSFTeSe7FtKyQkrlPvHwJOW3SLd8Oyg==", + "version": "20.14.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.10.tgz", + "integrity": "sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ==", "dev": true, "dependencies": { "undici-types": "~5.26.4" @@ -1493,8 +1955,7 @@ "node_modules/@types/prop-types": { "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", - "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", - "devOptional": true + "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==" }, "node_modules/@types/qs": { "version": "6.9.15", @@ -1506,7 +1967,6 @@ "version": "18.3.3", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==", - "devOptional": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -2295,6 +2755,18 @@ "node": ">=6" } }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2311,6 +2783,15 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -2378,8 +2859,7 @@ "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -2438,6 +2918,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.3.5", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", @@ -2493,6 +2982,14 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/deepmerge": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz", + "integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -2534,6 +3031,14 @@ "node": ">=0.4.0" } }, + "node_modules/detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-node-es": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", @@ -3695,6 +4200,30 @@ "node": ">= 6" } }, + "node_modules/formik": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/formik/-/formik-2.4.6.tgz", + "integrity": "sha512-A+2EI7U7aG296q2TLGvNapDNTZp1khVt5Vk0Q/fyfSROss0V/V6+txt2aJnwEos44IxTCW/LYAi/zgWzlevj+g==", + "funding": [ + { + "type": "individual", + "url": "https://opencollective.com/formik" + } + ], + "dependencies": { + "@types/hoist-non-react-statics": "^3.3.1", + "deepmerge": "^2.1.1", + "hoist-non-react-statics": "^3.3.0", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "react-fast-compare": "^2.0.1", + "tiny-warning": "^1.0.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -4020,6 +4549,19 @@ "node": ">= 0.4" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, "node_modules/husky": { "version": "9.0.11", "resolved": "https://registry.npmjs.org/husky/-/husky-9.0.11.tgz", @@ -4149,6 +4691,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" + }, "node_modules/is-async-function": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", @@ -4679,8 +5226,12 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" }, "node_modules/lodash.merge": { "version": "4.6.2", @@ -5697,6 +6248,11 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "dev": true }, + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==" + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -5767,6 +6323,11 @@ "react": "^18.3.1" } }, + "node_modules/react-fast-compare": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", + "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==" + }, "node_modules/react-hook-form": { "version": "7.52.1", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.52.1.tgz", @@ -5855,6 +6416,18 @@ } } }, + "node_modules/react-toastify": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-10.0.5.tgz", + "integrity": "sha512-mNKt2jBXJg4O7pSdbNUfDdTsK9FIdikfsIE/yUCxbAEXl4HMyJaivrVFcn3Elvt5xvCQYhUZm+hqTIu1UXM3Pw==", + "dependencies": { + "clsx": "^2.1.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -6090,7 +6663,6 @@ "version": "7.6.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", - "dev": true, "bin": { "semver": "bin/semver.js" }, @@ -6129,6 +6701,45 @@ "node": ">= 0.4" } }, + "node_modules/sharp": { + "version": "0.33.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.4.tgz", + "integrity": "sha512-7i/dt5kGl7qR4gwPRD2biwD2/SvBn3O04J77XKFgL2OnZtQw+AG9wnuS/csmu80nPRHLYE9E41fyEiG8nhH6/Q==", + "hasInstallScript": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.0" + }, + "engines": { + "libvips": ">=8.15.2", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.4", + "@img/sharp-darwin-x64": "0.33.4", + "@img/sharp-libvips-darwin-arm64": "1.0.2", + "@img/sharp-libvips-darwin-x64": "1.0.2", + "@img/sharp-libvips-linux-arm": "1.0.2", + "@img/sharp-libvips-linux-arm64": "1.0.2", + "@img/sharp-libvips-linux-s390x": "1.0.2", + "@img/sharp-libvips-linux-x64": "1.0.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.2", + "@img/sharp-libvips-linuxmusl-x64": "1.0.2", + "@img/sharp-linux-arm": "0.33.4", + "@img/sharp-linux-arm64": "0.33.4", + "@img/sharp-linux-s390x": "0.33.4", + "@img/sharp-linux-x64": "0.33.4", + "@img/sharp-linuxmusl-arm64": "0.33.4", + "@img/sharp-linuxmusl-x64": "0.33.4", + "@img/sharp-wasm32": "0.33.4", + "@img/sharp-win32-ia32": "0.33.4", + "@img/sharp-win32-x64": "0.33.4" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -6176,6 +6787,14 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -6571,6 +7190,16 @@ "node": ">=0.8" } }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==" + }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6582,6 +7211,11 @@ "node": ">=8.0" } }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==" + }, "node_modules/ts-api-utils": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", @@ -7074,6 +7708,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yup": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.4.0.tgz", + "integrity": "sha512-wPbgkJRCqIf+OHyiTBQoJiP5PFuAXaWiJK6AmYkzQAh5/c2K9hzSApBZG5wV9KoKSePF7sAxmNSvh/13YHkFDg==", + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + }, + "node_modules/yup/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "3.23.8", "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", diff --git a/package.json b/package.json index fd0a12f9..450b39fe 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@lukemorales/query-key-factory": "^1.3.4", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-avatar": "^1.1.0", + "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-radio-group": "^1.2.0", @@ -28,21 +29,26 @@ "axios": "^1.7.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "date-fns": "^3.6.0", + "formik": "^2.4.6", "lucide-react": "^0.402.0", "next": "14.2.4", "qs": "^6.12.2", "react": "^18", "react-dom": "^18", "react-hook-form": "^7.52.1", + "react-toastify": "^10.0.5", + "sharp": "^0.33.4", "tailwind-merge": "^2.4.0", "tailwindcss-animate": "^1.0.7", + "yup": "^1.4.0", "zod": "^3.23.8" }, "devDependencies": { "@tanstack/eslint-plugin-query": "^5.50.0", - "@types/node": "^20", + "@types/node": "^20.14.10", "@types/qs": "^6.9.15", - "@types/react": "^18", + "@types/react": "^18.3.3", "@types/react-dom": "^18", "@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/parser": "^7.2.0", @@ -61,7 +67,7 @@ "postcss": "8.4.39", "prettier": "^3.2.5", "prettier-eslint": "^16.3.0", - "tailwindcss": "3.4.4", - "typescript": "^5" + "tailwindcss": "^3.4.4", + "typescript": "^5.5.3" } } diff --git a/public/ProfileTestImage.jpg b/public/ProfileTestImage.jpg new file mode 100644 index 00000000..3d7e85d8 Binary files /dev/null and b/public/ProfileTestImage.jpg differ diff --git a/public/favicon.ico b/public/favicon.ico deleted file mode 100644 index 718d6fea..00000000 Binary files a/public/favicon.ico and /dev/null differ diff --git a/public/icon/BW/AngryFaceBWIcon.svg b/public/icon/BW/AngryFaceBWIcon.svg new file mode 100644 index 00000000..c499add3 --- /dev/null +++ b/public/icon/BW/AngryFaceBWIcon.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/public/icon/BW/HeartFaceBWIcon.svg b/public/icon/BW/HeartFaceBWIcon.svg new file mode 100644 index 00000000..5702c0f9 --- /dev/null +++ b/public/icon/BW/HeartFaceBWIcon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/icon/BW/SadFaceBWIcon.svg b/public/icon/BW/SadFaceBWIcon.svg new file mode 100644 index 00000000..90df96bd --- /dev/null +++ b/public/icon/BW/SadFaceBWIcon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/icon/BW/SmileFaceBWIcon.svg b/public/icon/BW/SmileFaceBWIcon.svg new file mode 100644 index 00000000..58be6715 --- /dev/null +++ b/public/icon/BW/SmileFaceBWIcon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/icon/BW/ThinkFaceBWIcon.svg b/public/icon/BW/ThinkFaceBWIcon.svg new file mode 100644 index 00000000..2ac21adc --- /dev/null +++ b/public/icon/BW/ThinkFaceBWIcon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/icon/Color/AngryFaceColorIcon.svg b/public/icon/Color/AngryFaceColorIcon.svg new file mode 100644 index 00000000..ca6e754a --- /dev/null +++ b/public/icon/Color/AngryFaceColorIcon.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/public/icon/Color/HeartFaceColorIcon.svg b/public/icon/Color/HeartFaceColorIcon.svg new file mode 100644 index 00000000..9db0bbd0 --- /dev/null +++ b/public/icon/Color/HeartFaceColorIcon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/icon/Color/SadFaceColorIcon.svg b/public/icon/Color/SadFaceColorIcon.svg new file mode 100644 index 00000000..58e96e31 --- /dev/null +++ b/public/icon/Color/SadFaceColorIcon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/icon/Color/SmileFaceColorIcon.svg b/public/icon/Color/SmileFaceColorIcon.svg new file mode 100644 index 00000000..3e66c738 --- /dev/null +++ b/public/icon/Color/SmileFaceColorIcon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/icon/Color/ThinkFaceColorIcon.svg b/public/icon/Color/ThinkFaceColorIcon.svg new file mode 100644 index 00000000..0e2bab7d --- /dev/null +++ b/public/icon/Color/ThinkFaceColorIcon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/icon/arrow-bottom-icon.svg b/public/icon/arrow-bottom-icon.svg new file mode 100644 index 00000000..8e0e4d20 --- /dev/null +++ b/public/icon/arrow-bottom-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icon/arrow-left-icon.svg b/public/icon/arrow-left-icon.svg new file mode 100644 index 00000000..a54a7cb4 --- /dev/null +++ b/public/icon/arrow-left-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icon/arrow-right-icon.svg b/public/icon/arrow-right-icon.svg new file mode 100644 index 00000000..dc5959d5 --- /dev/null +++ b/public/icon/arrow-right-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icon/profile-icon.svg b/public/icon/profile-icon.svg new file mode 100644 index 00000000..e7109ea6 --- /dev/null +++ b/public/icon/profile-icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/icon/search-icon.svg b/public/icon/search-icon.svg new file mode 100644 index 00000000..02de48b2 --- /dev/null +++ b/public/icon/search-icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/icon/share-icon.svg b/public/icon/share-icon.svg new file mode 100644 index 00000000..4d1c6d03 --- /dev/null +++ b/public/icon/share-icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/lg.svg b/public/lg.svg new file mode 100644 index 00000000..a4d3364f --- /dev/null +++ b/public/lg.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/logo-google.svg b/public/logo-google.svg new file mode 100644 index 00000000..5b169484 --- /dev/null +++ b/public/logo-google.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/logo-kakao.svg b/public/logo-kakao.svg new file mode 100644 index 00000000..f546e64d --- /dev/null +++ b/public/logo-kakao.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/logo-naver.svg b/public/logo-naver.svg new file mode 100644 index 00000000..dbec93dd --- /dev/null +++ b/public/logo-naver.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/md.svg b/public/md.svg new file mode 100644 index 00000000..9ada4505 --- /dev/null +++ b/public/md.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/next.svg b/public/next.svg deleted file mode 100644 index 5174b28c..00000000 --- a/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg deleted file mode 100644 index d2f84222..00000000 --- a/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/apis/.http b/src/apis/.http new file mode 100644 index 00000000..e69de29b diff --git a/src/apis/add.ts b/src/apis/add.ts new file mode 100644 index 00000000..66a6b010 --- /dev/null +++ b/src/apis/add.ts @@ -0,0 +1,9 @@ +import { AddEpigramRequestType, AddEpigramResponseType } from '@/schema/addEpigram'; +import httpClient from '.'; + +const postEpigram = async (request: AddEpigramRequestType): Promise => { + const response = await httpClient.post('/epigrams', request); + return response.data; +}; + +export default postEpigram; diff --git a/src/apis/auth.ts b/src/apis/auth.ts new file mode 100644 index 00000000..e13efb7d --- /dev/null +++ b/src/apis/auth.ts @@ -0,0 +1,12 @@ +import type { PostSigninRequestType, PostSigninResponseType, PostSignUpRequestType, PostSignUpResponseType } from '@/schema/auth'; +import httpClient from '.'; + +export const postSignin = async (request: PostSigninRequestType): Promise => { + const response = await httpClient.post('/auth/signIn', request); + return response.data; +}; + +export const postSignup = async (request: PostSignUpRequestType): Promise => { + const response = await httpClient.post('/auth/signUp', request); + return response.data; +}; diff --git a/src/apis/emotion.ts b/src/apis/emotion.ts new file mode 100644 index 00000000..8158e5a1 --- /dev/null +++ b/src/apis/emotion.ts @@ -0,0 +1,12 @@ +import { GetMonthlyEmotionLogsRequestType, GetMonthlyEmotionLogsResponseType } from '@/schema/emotion'; + +import httpClient from '.'; + +const getMonthlyEmotionLogs = async (request: GetMonthlyEmotionLogsRequestType): Promise => { + const response = await httpClient.get(`/emotionLogs/monthly`, { + params: request, + }); + return response.data; +}; + +export default getMonthlyEmotionLogs; diff --git a/src/apis/epigramComment.ts b/src/apis/epigramComment.ts new file mode 100644 index 00000000..4368fb9a --- /dev/null +++ b/src/apis/epigramComment.ts @@ -0,0 +1,46 @@ +import httpClient from '@/apis/index'; +import { CommentRequestSchema, CommentRequestType, CommentResponseSchema, CommentResponseType } from '@/schema/comment'; +import { PostCommentRequest, PatchCommentRequest } from '@/types/epigram.types'; + +export const getEpigramComments = async (params: CommentRequestType): Promise => { + try { + // 요청 파라미터 유효성 검사 + const validatedParams = CommentRequestSchema.parse(params); + + const { id, limit, cursor } = validatedParams; + + // NOTE: URL의 쿼리 문자열을 사용 + // NOTE : cursor값이 있다면 ?limit=3&cursor=100, 없다면 ?limit=3,(숫자는 임의로 지정한 것) + const queryParams = new URLSearchParams({ + limit: limit.toString(), + ...(cursor !== undefined && { cursor: cursor.toString() }), + }); + + const response = await httpClient.get(`/epigrams/${id}/comments?${queryParams.toString()}`); + + // 응답 데이터 유효성 검사 + const validatedData = CommentResponseSchema.parse(response.data); + + return validatedData; + } catch (error) { + if (error instanceof Error) { + throw new Error(`댓글을 불러오는데 실패했습니다: ${error.message}`); + } + throw error; + } +}; + +export const postComment = async (commentData: PostCommentRequest) => { + const response = await httpClient.post('/comments', commentData); + return response.data; +}; + +export const patchComment = async (commentId: number, commentData: PatchCommentRequest) => { + const response = await httpClient.patch(`/comments/${commentId}`, commentData); + return response.data; +}; + +export const deleteComment = async (commentId: number) => { + const response = await httpClient.delete(`/comments/${commentId}`); + return response.data; +}; diff --git a/src/apis/getEmotion.ts b/src/apis/getEmotion.ts new file mode 100644 index 00000000..044ff3c3 --- /dev/null +++ b/src/apis/getEmotion.ts @@ -0,0 +1,25 @@ +import { EmotionType } from '@/types/emotion'; +import type { GetEmotionResponseType } from '@/schema/emotion'; +import { translateEmotionToKorean } from '@/utils/emotionMap'; +import httpClient from '.'; +import { getMe } from './user'; + +const getEmotion = async (): Promise => { + const user = await getMe(); + if (!user) { + throw new Error('로그인이 필요합니다.'); + } + + const response = await httpClient.get('/emotionLogs/today', { + params: { userId: user.id }, + }); + + if (response.status === 204) { + return null; // No content + } + + const koreanEmotion = translateEmotionToKorean(response.data.emotion); + return koreanEmotion; +}; + +export default getEmotion; diff --git a/src/apis/getEpigrams.ts b/src/apis/getEpigrams.ts new file mode 100644 index 00000000..9685bc60 --- /dev/null +++ b/src/apis/getEpigrams.ts @@ -0,0 +1,12 @@ +import { GetEpigramsParamsType, GetEpigramsResponseType, GetEpigramsResponse } from '@/schema/epigrams'; +import httpClient from '.'; + +const getEpigrams = async (params: GetEpigramsParamsType): Promise => { + const response = await httpClient.get(`/epigrams`, { params }); + + // 데이터 일치하는지 확인 + const parsedResponse = GetEpigramsResponse.parse(response.data); + return parsedResponse; +}; + +export default getEpigrams; diff --git a/src/apis/index.ts b/src/apis/index.ts index 29949fc2..0a4b7625 100644 --- a/src/apis/index.ts +++ b/src/apis/index.ts @@ -1,10 +1,94 @@ import axios from 'axios'; import qs from 'qs'; +// NOTE: axios 선언 const httpClient = axios.create({ baseURL: process.env.NEXT_PUBLIC_BASE_URL, - headers: { 'Content-Type': 'application/json' }, paramsSerializer: (parameters) => qs.stringify(parameters, { arrayFormat: 'repeat', encode: false }), }); +// NOTE: eslint-disable no-param-reassign 미해결로 인한 설정 +httpClient.interceptors.request.use((config) => { + const accessToken = localStorage.getItem('accessToken'); + /* eslint-disable no-param-reassign */ + if (accessToken) config.headers.Authorization = `Bearer ${accessToken}`; + /* eslint-enable no-param-reassign */ + return config; +}); + +httpClient.interceptors.response.use( + (response) => response, + + (error) => { + if (error.response && error.response.status === 401) { + const refreshToken = localStorage.getItem('refreshToken'); + + if (!refreshToken) { + window.location.href = '/auth/SignIn'; + return Promise.reject(error); + } + + return httpClient + .post('/auth/refresh-token', null, { + headers: { Authorization: `Bearer ${refreshToken}` }, + }) + .then((response) => { + const { accessToken, refreshToken: newRefreshToken } = response.data; + localStorage.setItem('accessToken', accessToken); + localStorage.setItem('refreshToken', newRefreshToken); + + const originalRequest = error.config; + return httpClient(originalRequest); + }) + .catch(() => { + window.location.href = '/auth/SignIn'; + return Promise.reject(error); + }); + } + return Promise.reject(error); + }, +); + export default httpClient; + +// NOTE: eslint-disable no-param-reassign 미해결로 인한 설정 +httpClient.interceptors.request.use((config) => { + const accessToken = localStorage.getItem('accessToken'); + /* eslint-disable no-param-reassign */ + if (accessToken) config.headers.Authorization = `Bearer ${accessToken}`; + /* eslint-enable no-param-reassign */ + return config; +}); + +httpClient.interceptors.response.use( + (response) => response, + + (error) => { + if (error.response && error.response.status === 401) { + const refreshToken = localStorage.getItem('refreshToken'); + + if (!refreshToken) { + window.location.href = '/auth/SignIn'; + return Promise.reject(error); + } + + return httpClient + .post('/auth/refresh-token', null, { + headers: { Authorization: `Bearer ${refreshToken}` }, + }) + .then((response) => { + const { accessToken, refreshToken: newRefreshToken } = response.data; + localStorage.setItem('accessToken', accessToken); + localStorage.setItem('refreshToken', newRefreshToken); + + const originalRequest = error.config; + return httpClient(originalRequest); + }) + .catch(() => { + window.location.href = '/auth/SignIn'; + return Promise.reject(error); + }); + } + return Promise.reject(error); + }, +); diff --git a/src/apis/oauth.ts b/src/apis/oauth.ts new file mode 100644 index 00000000..ae6bd068 --- /dev/null +++ b/src/apis/oauth.ts @@ -0,0 +1,11 @@ +import axios from 'axios'; + +const postOauth = async (code: string) => { + const response = await axios.post(`${process.env.NEXT_PUBLIC_BASE_URL}/auth/signIn/KAKAO`, { + redirectUri: process.env.NEXT_PUBLIC_REDIRECT_URI, + token: code, + }); + return response.data; +}; + +export default postOauth; diff --git a/src/apis/postEmotion.ts b/src/apis/postEmotion.ts new file mode 100644 index 00000000..168a8c13 --- /dev/null +++ b/src/apis/postEmotion.ts @@ -0,0 +1,15 @@ +import { EmotionType } from '@/types/emotion'; +import type { PostEmotionRequestType, PostEmotionResponseType } from '@/schema/emotion'; +import { translateEmotionToEnglish } from '@/utils/emotionMap'; +import httpClient from '.'; + +const postEmotion = async (emotion: EmotionType): Promise => { + const englishEmotion = translateEmotionToEnglish(emotion); + const request: PostEmotionRequestType = { emotion: englishEmotion }; + + const response = await httpClient.post('/emotionLogs/today', request); + + return response.data; +}; + +export default postEmotion; diff --git a/src/apis/queries.ts b/src/apis/queries.ts index 1f84081c..0010c375 100644 --- a/src/apis/queries.ts +++ b/src/apis/queries.ts @@ -1,6 +1,8 @@ import { createQueryKeyStore } from '@lukemorales/query-key-factory'; import { GetUserRequestType } from '@/schema/user'; +import { GetMonthlyEmotionLogsRequestType } from '@/schema/emotion'; import { getMe, getUser } from './user'; +import getMonthlyEmotionLogs from './emotion'; const quries = createQueryKeyStore({ user: { @@ -13,6 +15,12 @@ const quries = createQueryKeyStore({ queryFn: () => getUser(request), }), }, + emotion: { + getMonthlyEmotionLogs: (request: GetMonthlyEmotionLogsRequestType) => ({ + queryKey: ['getMonthlyEmotionLogs', request], + queryFn: () => getMonthlyEmotionLogs(request), + }), + }, }); export default quries; diff --git a/src/apis/user.ts b/src/apis/user.ts index 395b0167..5f924dea 100644 --- a/src/apis/user.ts +++ b/src/apis/user.ts @@ -1,18 +1,25 @@ -import type { GetUserReponseType, GetUserRequestType, PatchMeRequestType } from '@/schema/user'; +import type { GetUserResponseType, GetUserRequestType, PatchMeRequestType, PostPresignedUrlRequestType, PostPresignedUrlResponseType } from '@/schema/user'; import httpClient from '.'; -export const getMe = async (): Promise => { +export const getMe = async (): Promise => { const response = await httpClient.get('/users/me'); return response.data; }; -export const getUser = async (request: GetUserRequestType): Promise => { +export const getUser = async (request: GetUserRequestType): Promise => { const { id } = request; const response = await httpClient.get(`/users/${id}`); return response.data; }; -export const updateMe = async (request: PatchMeRequestType): Promise => { +export const updateMe = async (request: PatchMeRequestType): Promise => { const response = await httpClient.patch('/users/me', { ...request }); return response.data; }; + +export const createPresignedUrl = async (request: PostPresignedUrlRequestType): Promise => { + const formData = new FormData(); + formData.append('image', request.image); + const response = await httpClient.post('/images/upload', formData); + return response.data; +}; diff --git a/src/components/Card/CommentCard.tsx b/src/components/Card/CommentCard.tsx new file mode 100644 index 00000000..a58f0e6f --- /dev/null +++ b/src/components/Card/CommentCard.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import Image from 'next/image'; +import { sizeStyles, textSizeStyles, gapStyles, paddingStyles, contentWidthStyles } from '@/styles/CommentCardStyles'; + +export interface CommentCardProps { + status: 'edit' | 'complete'; +} + +function CommentCard({ status }: CommentCardProps) { + return ( + + + + + {' '} + + + + + + + {/* 테스트 텍스트입니다. */} + 지킬과 하이드 + + + {/* 테스트 텍스트입니다. */} + 1시간 전 + + + {status === 'edit' && ( + + 수정 + 삭제 + + )} + + + {/* 테스트 텍스트입니다. */} + 오늘 하루 우울했었는데 덕분에 많은 힘 얻고 갑니다. 연금술사 책 다시 사서 오랜만에 읽어봐야겠어요! + + + + + ); +} + +export default CommentCard; diff --git a/src/components/Card/EpigramCard.tsx b/src/components/Card/EpigramCard.tsx new file mode 100644 index 00000000..07ceb392 --- /dev/null +++ b/src/components/Card/EpigramCard.tsx @@ -0,0 +1,63 @@ +import React from 'react'; + +// figma 상으로는 sm ~ 3xl 사이즈로 구현되어 있는데, tailwind 환경을 반영해 +// xs ~ 2xl 으로 정의했습니다. +const sizeStyles = { + xs: 'w-[286px] max-h-[132px]', + sm: 'sm:w-[312px] sm:max-h-[152px]', + md: 'md:w-[384px] md:max-h-[180px]', + lg: 'lg:w-[540px] lg:max-h-[160px]', + xl: 'xl:w-[640px] xl:max-h-[196px]', + '2xl': '2xl:w-[744px] 2xl:max-h-[196px]', +}; + +const textSizeStyles = { + xs: 'text-xs', + sm: 'sm:text-sm', + md: 'md:text-base', + lg: 'lg:text-xl', + xl: 'xl:text-2xl', + '2xl': '2xl:text-2xl', +}; + +function EpigramCard() { + return ( + + + {/* eslint-disable-next-line */} + {/* 줄무늬를 만들려면 비어있는 div가 필요합니다. */} + + + + {/* 테스트 텍스트입니다. */} + 오랫동안 꿈을 그리는 사람은 마침내 그 꿈을 닮아 간다. + + + {/* 테스트 텍스트입니다. */}- 앙드레 말로 - + + + + + + + {/* 테스트 텍스트입니다. */} + #나아가야할때 + + + {/* 테스트 텍스트입니다. */} + #꿈을이루고싶을때 + + + + ); +} + +export default EpigramCard; diff --git a/src/components/Emotion/EmotionCard.tsx b/src/components/Emotion/EmotionCard.tsx new file mode 100644 index 00000000..74134281 --- /dev/null +++ b/src/components/Emotion/EmotionCard.tsx @@ -0,0 +1,113 @@ +/* + 1개의 감정 아이콘 카드를 랜더링 합니다. + 아이콘의 타입, 상태, 크기, 클릭 이벤트를 관리합니다. + 아이콘 타입과 상태에 따라 아이콘의 모양과 스타일을 조정합니다. + */ + +import React from 'react'; +import cn from '@/lib/utils'; +import Image from 'next/image'; +import { EmotionIconCardProps } from '@/types/emotion'; + +// 아이콘 파일 경로 매핑 +const iconPaths = { + Color: { + 감동: '/icon/Color/HeartFaceColorIcon.svg', + 기쁨: '/icon/Color/SmileFaceColorIcon.svg', + 고민: '/icon/Color/ThinkFaceColorIcon.svg', + 슬픔: '/icon/Color/SadFaceColorIcon.svg', + 분노: '/icon/Color/AngryFaceColorIcon.svg', + }, + BW: { + 감동: '/icon/BW/HeartFaceBWIcon.svg', + 기쁨: '/icon/BW/SmileFaceBWIcon.svg', + 고민: '/icon/BW/ThinkFaceBWIcon.svg', + 슬픔: '/icon/BW/SadFaceBWIcon.svg', + 분노: '/icon/BW/AngryFaceBWIcon.svg', + }, +}; + +// EmotionIconCard 컴포넌트 함수 선언 +function EmotionIconCard({ iconType = '감동', state = 'Default', size = 'sm', onClick }: EmotionIconCardProps) { + // 크기에 따른 클래스 설정 + let sizeClass = ''; + let iconSizeClass = ''; + let textSizeClass = ''; + switch (size) { + case 'lg': + sizeClass = 'w-20 h-28'; + iconSizeClass = 'w-12 h-12'; + textSizeClass = 'text-base leading-relaxed'; + break; + case 'md': + sizeClass = 'w-16 h-24'; + iconSizeClass = 'w-10 h-10'; + textSizeClass = 'text-sm leading-normal'; + break; + case 'sm': + default: + sizeClass = 'w-14 h-21'; + iconSizeClass = 'w-8 h-8'; + textSizeClass = 'text-xs leading-tight'; + break; + } + + // 상태에 따른 아이콘 경로 설정 + const iconPath = state === 'Clicked' || state === 'Default' ? iconPaths.Color[iconType] : iconPaths.BW[iconType]; + + // 상태에 따른 클래스 설정 + let borderClass = ''; + const textColorClass = 'text-neutral-400'; + let backgroundClass = 'bg-slate-400/20'; + let textVisibilityClass = ''; + + if (state === 'Clicked') { + textVisibilityClass = 'hidden'; + backgroundClass = 'bg-transparent'; + + // iconType에 따라 다른 border 색상을 설정 + switch (iconType) { + case '감동': + borderClass = 'border-4 border-illust-yellow'; + break; + case '기쁨': + borderClass = 'border-4 border-illust-green'; + break; + case '고민': + borderClass = 'border-4 border-illust-purple'; + break; + case '슬픔': + borderClass = 'border-4 border-illust-blue'; + break; + case '분노': + borderClass = 'border-4 border-illust-red'; + break; + default: + borderClass = 'border-4 border-sub_blue_1'; + break; + } + } + + return ( + { + if (e.key === 'Enter' || e.key === ' ') { + if (onClick) { + onClick(); + } + } + }} + > + + + + {iconType} + + ); +} + +export default EmotionIconCard; diff --git a/src/components/Emotion/EmotionSaveToast.tsx b/src/components/Emotion/EmotionSaveToast.tsx new file mode 100644 index 00000000..e7d24105 --- /dev/null +++ b/src/components/Emotion/EmotionSaveToast.tsx @@ -0,0 +1,34 @@ +/* + * 오늘의 감정을 선택하면 표시되는 toast입니다. + * 감정을 확인하기 위해 마이페이지로 연결됩니다. + */ + +import React, { useEffect } from 'react'; +import { useToast } from '@/components/ui/use-toast'; +import { ToastAction } from '@/components/ui/toast'; +import { useRouter } from 'next/router'; + +interface EmotionSaveToastProps { + iconType: string; +} + +function EmotionSaveToast({ iconType }: EmotionSaveToastProps) { + const { toast } = useToast(); + const router = useRouter(); + + useEffect(() => { + toast({ + title: '오늘의 감정이 저장되었습니다.', + description: `오늘의 감정: ${iconType}`, + action: ( + router.push('/mypage')}> + 확인하기 + + ), + }); + }, [iconType, toast, router]); + + return null; +} + +export default EmotionSaveToast; diff --git a/src/components/Emotion/EmotionSelector.tsx b/src/components/Emotion/EmotionSelector.tsx new file mode 100644 index 00000000..4375c3ca --- /dev/null +++ b/src/components/Emotion/EmotionSelector.tsx @@ -0,0 +1,111 @@ +import React, { useState, useEffect } from 'react'; +import useMediaQuery from '@/hooks/useMediaQuery'; +import EmotionIconCard from '@/components/Emotion/EmotionCard'; +import { EmotionType, EmotionState } from '@/types/emotion'; +import usePostEmotion from '@/hooks/usePostEmotion'; +import { useGetEmotion } from '@/hooks/useGetEmotion'; +import EmotionSaveToast from './EmotionSaveToast'; + +/** + * EmotionSelector 컴포넌트는 여러 개의 EmotionIconCard를 관리하고 + * 사용자의 오늘의 감정을 선택하고 저장하고 출력합니다. + */ +function EmotionSelector() { + // 반응형 디자인을 위한 미디어 쿼리 훅 + const isTablet = useMediaQuery('(min-width: 768px) and (max-width: 1024px)'); + const isMobile = useMediaQuery('(max-width: 767px)'); + + // 감정 카드 상태 관리를 위한 useState 훅 + const [states, setStates] = useState>({ + 감동: 'Default', + 기쁨: 'Default', + 고민: 'Default', + 슬픔: 'Default', + 분노: 'Default', + }); + + // 현재 선택된 감정을 관리하는 useState 훅 + const [selectedEmotion, setSelectedEmotion] = useState(null); + // 오늘의 감정을 조회하기 위한 훅 + const { data: emotion, error: getError, isLoading: isGetLoading } = useGetEmotion(); + // 감정을 저장하기 위한 훅 + const postEmotionMutation = usePostEmotion(); + + // 컴포넌트가 마운트될 때 한 번만 실행되는 useEffect 훅 + // 오늘의 감정을 조회하고 상태를 업데이트합니다. + useEffect(() => { + if (emotion) { + setStates((prevStates) => ({ + ...prevStates, + [emotion]: 'Clicked', + })); + } + }, [emotion]); + + /** + * 감정 카드 클릭 핸들러 + * 사용자가 감정 카드를 클릭했을 때 호출됩니다. + * 클릭된 감정 카드를 'Clicked' 상태로 설정하고 나머지 카드는 'Unclicked' 상태로 설정합니다. + * 감정을 서버에 저장합니다. + * @param iconType - 클릭된 감정의 타입 + */ + const handleCardClick = async (iconType: EmotionType) => { + setStates((prevStates) => { + const newStates = { ...prevStates }; + + if (prevStates[iconType] === 'Clicked') { + // 현재 클릭된 카드가 다시 클릭되면 모든 카드를 Default로 설정 + Object.keys(newStates).forEach((key) => { + newStates[key as EmotionType] = 'Default'; + }); + } else { + // 하나의 카드가 클릭되면 그 카드만 Clicked, 나머지는 Unclicked로 설정 + Object.keys(newStates).forEach((key) => { + newStates[key as EmotionType] = key === iconType ? 'Clicked' : 'Unclicked'; + }); + } + + return newStates; + }); + + // 오늘의 감정 저장 + postEmotionMutation.mutate(iconType, { + onSuccess: (_, clickedIconType) => { + setSelectedEmotion(clickedIconType); + }, + onError: (error: unknown) => { + // eslint-disable-next-line + console.error(error); + }, + }); + }; + + // 반응형 디자인을 위한 카드 크기 설정 + let containerClass = 'w-[544px] h-[136px] gap-4'; + let cardSize: 'lg' | 'md' | 'sm' = 'lg'; + + if (isTablet) { + containerClass = 'w-[352px] h-[96px] gap-2'; + cardSize = 'md'; + } else if (isMobile) { + containerClass = 'w-[312px] h-[84px] gap-2'; + cardSize = 'sm'; + } + + if (isGetLoading) return Loading...; + if (getError) return {getError.message}; + + return ( + <> + + {(['감동', '기쁨', '고민', '슬픔', '분노'] as const).map((iconType) => ( + handleCardClick(iconType)} /> + ))} + + {/* 감정이 선택되었을 때 토스트 메시지 표시 */} + {selectedEmotion && } + > + ); +} + +export default EmotionSelector; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 00000000..d342347f --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import { useRouter } from 'next/router'; +import Image from 'next/image'; +import { useToast } from '../ui/use-toast'; +import LOGO_ICON from '../../../public/epigram-icon.png'; +import ARROW_LEFT_ICON from '../../../public/icon/arrow-left-icon.svg'; +import PROFILE_ICON from '../../../public/icon/profile-icon.svg'; +import SEARCH_ICON from '../../../public/icon/search-icon.svg'; +import SHARE_ICON from '../../../public/icon/share-icon.svg'; + +// NOTE 네비게이션 바를 나타내는 컴포넌트 입니다. +// NOTE 상위 컴포넌트에서 Props를 받아 원하는 스타일을 보여줍니다. +// NOTE 사용 예시 +// NOTE +// NOTE {}} />; +// NOTE icon: 'back'을 사용할 경우 routerPage의 값을 무조건 지정해줘야 합니다. +// NOTE isLogo={false}일 경우 insteadOfLogo의 값을 무조건 지정해줘야 합니다. +// NOTE isButton 일 경우 textInButton의 값을 무조건 지정해줘야 합니다. +// NOTE SHARE_ICON 추가 시 토스트 기능도 사용하려면 해당 컴포넌트 아래 를 추가해주세요. + +export interface HeaderProps { + icon: 'back' | 'search' | ''; + routerPage: string; + isLogo: boolean; + insteadOfLogo: string; + isProfileIcon: boolean; + isShareIcon: boolean; + isButton: boolean; + textInButton: string; + disabled: boolean; + onClick: (e: React.MouseEvent) => void; +} + +function Header({ isLogo, icon, insteadOfLogo, isButton, isProfileIcon, isShareIcon, textInButton, routerPage, disabled, onClick }: HeaderProps) { + const router = useRouter(); + const { toast } = useToast(); + + // 페이지 이동 함수 + const handleNavigateTo = (path: string) => { + router.push(path); + }; + + // 현재 링크 복사 함수 + const handleCopyToClipboard = async () => { + try { + // 현재 URL 가져오기 + const currentURL = window.location.href; + // 클립보드에 복사하기 + await navigator.clipboard.writeText(currentURL); + toast({ + title: '성공', + description: '링크가 클립보드에 복사되었습니다!', + }); + } catch (err) { + toast({ + title: '실패', + description: '링크 복사에 실패했습니다. 다시 시도해 주세요.', + variant: 'destructive', + }); + } + }; + + return ( + + + + {icon === 'back' && ( + handleNavigateTo(routerPage)} aria-label='뒤로가기 버튼'> + + + )} + {icon === 'search' && ( + handleNavigateTo('/search')} aria-label='검색 버튼'> + + + )} + + + {isLogo ? ( + handleNavigateTo('/')} aria-label='홈으로 이동'> + + Epigram + + ) : ( + {insteadOfLogo} + )} + + + {isProfileIcon && ( + handleNavigateTo('/mypage')} aria-label='프로필 페이지로 이동'> + + + )} + {isShareIcon && ( + + + + )} + {isButton && ( + + {textInButton} + + )} + + + + ); +} + +export default Header; diff --git a/src/components/index.tsx b/src/components/index.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/components/search/RecentSearches.tsx b/src/components/search/RecentSearches.tsx new file mode 100644 index 00000000..0b71f086 --- /dev/null +++ b/src/components/search/RecentSearches.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +interface RecentSearchesProps { + searches: string[]; + onSearch: (search: string) => void; + onClear: () => void; +} + +function RecentSearches({ searches, onSearch, onClear }: RecentSearchesProps) { + const handleSearchClick = (search: string) => { + onSearch(search); + }; + + const handleClearAll = () => { + onClear(); + }; + + return ( + + + 최근 검색어 + + 모두 지우기 + + + + + {searches.map((search) => ( + handleSearchClick(search)}> + {search} + + ))} + + + + ); +} + +export default RecentSearches; diff --git a/src/components/search/SearchBar.tsx b/src/components/search/SearchBar.tsx new file mode 100644 index 00000000..accf11f1 --- /dev/null +++ b/src/components/search/SearchBar.tsx @@ -0,0 +1,58 @@ +import React, { useState, useEffect } from 'react'; +import Image from 'next/image'; +import SEARCH_ICON from '../../../public/md.svg'; + +// TODO react-hook-form 사용 + +interface SearchBarProps { + onSearch: (search: string) => void; + currentSearch: string; +} + +function SearchBar({ onSearch, currentSearch }: SearchBarProps) { + const [searchInput, setSearchInput] = useState(''); + const [isFocused, setIsFocused] = useState(false); + + const handleChange = (e: React.ChangeEvent) => { + setSearchInput(e.target.value); + }; + + const handleFocus = () => { + setIsFocused(true); + }; + + const handleBlur = () => { + setIsFocused(false); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (searchInput.trim()) { + onSearch(searchInput); + setSearchInput(''); + } + }; + + // 최근 검색어 클릭 시 setSearchInput 상태 업데이트 + useEffect(() => { + setSearchInput(currentSearch); + }, [currentSearch]); + + return ( + + + + + + + ); +} + +export default SearchBar; diff --git a/src/components/search/SearchResults.tsx b/src/components/search/SearchResults.tsx new file mode 100644 index 00000000..8ecb893a --- /dev/null +++ b/src/components/search/SearchResults.tsx @@ -0,0 +1,101 @@ +import React, { useMemo } from 'react'; +import Link from 'next/link'; +import { GetEpigramsResponseType } from '@/schema/epigrams'; + +// TODO highlightedSections의 key 설정 부분에 더 나은 방법이 생각나면 변경 + +interface SearchResultsProps { + results: GetEpigramsResponseType | null; + query: string; + isLoading: boolean; +} + +// 텍스트 하이라이팅 함수 +function handleHighlightText(text: string, highlight: string) { + if (!highlight.trim()) { + return text; + } + + // 검색어(highlight)기준으로 검색 결과를 배열로 나눔(g: 중복 O, i: 대소문자 구분 X) + const highlightedSections = text.split(new RegExp(`(${highlight})`, 'gi')); + + // 검색어와 비교해서 같으면 하이라이팅, 다르면 그냥 반환 + return ( + <> + {highlightedSections.map((section, index) => { + const key = `${section}-${index}-${section.length}`; + return section.toLowerCase() === highlight.toLowerCase() ? ( + + {section} + + ) : ( + section + ); + })} + > + ); +} + +function SearchResults({ results, query, isLoading }: SearchResultsProps) { + // 태그와 내용 순서로 정렬 - 항상 useMemo를 호출하고, results가 null인 경우 빈 배열 반환 + const sortedResults = useMemo(() => { + if (!results) return []; + return results.list.sort((a, b) => { + const aHasTag = a.tags.some((tag) => tag.name.includes(query)); + const bHasTag = b.tags.some((tag) => tag.name.includes(query)); + + if (aHasTag && !bHasTag) return -1; + if (!aHasTag && bHasTag) return 1; + return 0; + }); + }, [results, query]); + + const filteredResults = useMemo( + () => sortedResults.filter((item) => item.content.includes(query) || item.author.includes(query) || item.tags.some((tag) => tag.name.includes(query))), + [sortedResults, query], + ); + + if (isLoading) { + return ( + + + 검색 결과를 불러오는 중 입니다... + + + ); + } + + if (!results || filteredResults.length === 0) { + return ( + + + 해당 검색어에 대한 결과가 없습니다. + + + ); + } + + return ( + + {filteredResults.map((item) => ( + + + + {handleHighlightText(item.content, query)} + - {handleHighlightText(item.author, query)} - + + + {item.tags.map((tag) => ( + + {handleHighlightText(`#${tag.name}`, query)} + + ))} + + + + ))} + + ); +} + +export default SearchResults; diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 00000000..f44152fd --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,65 @@ +import * as React from 'react'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { X } from 'lucide-react'; + +import cn from '@/lib/utils'; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +function DialogHeader({ className, ...props }: React.HTMLAttributes) { + return ; +} +DialogHeader.displayName = 'DialogHeader'; + +function DialogFooter({ className, ...props }: React.HTMLAttributes) { + return ; +} +DialogFooter.displayName = 'DialogFooter'; + +const DialogTitle = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { Dialog, DialogPortal, DialogOverlay, DialogClose, DialogTrigger, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription }; diff --git a/src/hooks/epigramQueryHook.ts b/src/hooks/epigramQueryHook.ts new file mode 100644 index 00000000..e2ca6679 --- /dev/null +++ b/src/hooks/epigramQueryHook.ts @@ -0,0 +1,24 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { AddEpigramFormType, AddEpigramResponseType } from '@/schema/addEpigram'; +import { MutationOptions } from '@/types/query'; +import postEpigram from '@/apis/add'; +import { AxiosError } from 'axios'; + +// TODO: 에피그램 수정과 삭제에도 사용 가능하게 훅 수정 예정 + +const useAddEpigram = (options?: MutationOptions) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (newEpigram: AddEpigramFormType) => postEpigram(newEpigram), + ...options, + onSuccess: (...args) => { + queryClient.invalidateQueries({ queryKey: ['epigrams'] }); + if (options?.onSuccess) { + options.onSuccess(...args); + } + }, + }); +}; + +export default useAddEpigram; diff --git a/src/hooks/useCalendar.ts b/src/hooks/useCalendar.ts new file mode 100644 index 00000000..44afc6a6 --- /dev/null +++ b/src/hooks/useCalendar.ts @@ -0,0 +1,67 @@ +import { getDaysInMonth } from 'date-fns'; +import { CALENDAR_LENGTH, DAY_OF_WEEK } from '../user/utill/constants'; + +interface CalendarData { + weekCalendarList: number[][]; // 주별 날짜 리스트 +} + +// 이전 달의 날짜를 계산하는 함수 +const getPreviousDays = (firstDayOfCurrentMonth: Date, totalPrevMonthDays: number): number[] => + // 현재 월의 첫 번째 날의 요일을 기준으로 이전 달의 날짜를 배열로 반환 + Array.from({ length: firstDayOfCurrentMonth.getDay() }, (_, index) => totalPrevMonthDays - firstDayOfCurrentMonth.getDay() + index + 1); +// 현재 월의 날짜를 배열로 반환하는 함수 +const getCurrentDays = (totalMonthDays: number): number[] => Array.from({ length: totalMonthDays }, (_, i) => i + 1); // 1부터 totalMonthDays까지의 배열 생성 +// 다음 달의 날짜를 계산하는 함수 +const getNextDays = (currentDayList: number[], prevDayList: number[]): number[] => { + // 다음 달의 날짜 수를 계산하여 배열로 반환 + const nextDayCount = CALENDAR_LENGTH - currentDayList.length - prevDayList.length; + return Array.from({ length: Math.max(nextDayCount, 0) }, (_, index) => index + 1); +}; + +const useCalendar = (currentDate: Date): CalendarData => { + // 현재 월의 총 날짜 수를 가져옴 + const totalMonthDays = getDaysInMonth(currentDate); + + // 이전 달의 마지막 날짜를 계산 + const prevMonthLastDate = new Date(currentDate.getFullYear(), currentDate.getMonth(), 0); + // 이전 달의 총 날짜 수를 가져옴 + const totalPrevMonthDays = getDaysInMonth(prevMonthLastDate); + + // 현재 월의 첫 번째 날짜를 계산 + const firstDayOfCurrentMonth = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1); + // 이전 달의 날짜 리스트 + const prevDayList = getPreviousDays(firstDayOfCurrentMonth, totalPrevMonthDays); + // 현재 월의 날짜 리스트 + const currentDayList = getCurrentDays(totalMonthDays); + // 다음 달의 날짜 리스트 + const nextDayList = getNextDays(currentDayList, prevDayList); + + // 전체 날짜 리스트 (이전 / 현재 / 다음 달 날짜 포함) + const currentCalendarList = [...prevDayList, ...currentDayList, ...nextDayList]; + + // 주별로 날짜 리스트를 분할 + const weekCalendarList: number[][] = []; + currentCalendarList.forEach((currDate, index) => { + const chunkIndex = Math.floor(index / DAY_OF_WEEK); + if (!weekCalendarList[chunkIndex]) { + weekCalendarList[chunkIndex] = []; // 주 배열이 없으면 초기화 + } + weekCalendarList[chunkIndex].push(currDate); // 누적값 반환 + }); + + // NOTE: 한 달이 5주 일 수도, 6주 일 수도 있을 때 5주인 경우 해당 달에 필요없는 다음 달의 날짜가 출력되기 때문에 (CALENDAR_LENGTH를 최대치인 42로 잡아서) 마지막 주의 첫 번째 숫자가 10이하의 날짜로 시작한다면 해당 배열을 삭제하도록 추가. + // TODO: 추후 다른 방법이 있다면 변경 할 예정 + if (weekCalendarList.length > 0) { + const lastWeek = weekCalendarList[weekCalendarList.length - 1]; + if (lastWeek[0] <= 10) { + weekCalendarList.pop(); + } + } + + // 캘린더 정보를 반환 + return { + weekCalendarList, // 주별 날짜 리스트 + }; +}; + +export default useCalendar; diff --git a/src/hooks/useDeleteCommentHook.ts b/src/hooks/useDeleteCommentHook.ts new file mode 100644 index 00000000..006019b5 --- /dev/null +++ b/src/hooks/useDeleteCommentHook.ts @@ -0,0 +1,31 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { deleteComment } from '@/apis/epigramComment'; +import { toast } from '@/components/ui/use-toast'; + +const useDeleteCommentMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (commentId: number) => deleteComment(commentId), + onSuccess: () => { + // 댓글 목록 쿼리 무효화 + queryClient.invalidateQueries({ queryKey: ['epigramComments'] }); + + // 성공 메시지 표시 + toast({ + title: '댓글 삭제 성공', + description: '댓글이 성공적으로 삭제되었습니다.', + }); + }, + onError: (error) => { + // 에러 메시지 표시 + toast({ + title: '댓글 삭제 실패', + description: `댓글 삭제 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`, + variant: 'destructive', + }); + }, + }); +}; + +export default useDeleteCommentMutation; diff --git a/src/hooks/useGetEmotion.ts b/src/hooks/useGetEmotion.ts new file mode 100644 index 00000000..e8555101 --- /dev/null +++ b/src/hooks/useGetEmotion.ts @@ -0,0 +1,13 @@ +import quries from '@/apis/queries'; +import getEmotion from '@/apis/getEmotion'; +import { EmotionType } from '@/types/emotion'; +import { GetMonthlyEmotionLogsRequestType } from '@/schema/emotion'; +import { useQuery } from '@tanstack/react-query'; + +export const useMonthlyEmotionLogs = (requset: GetMonthlyEmotionLogsRequestType) => useQuery(quries.emotion.getMonthlyEmotionLogs(requset)); + +export const useGetEmotion = () => + useQuery({ + queryKey: ['emotion'], + queryFn: getEmotion, + }); diff --git a/src/hooks/useGetEpigramsHooks.ts b/src/hooks/useGetEpigramsHooks.ts new file mode 100644 index 00000000..04c5554c --- /dev/null +++ b/src/hooks/useGetEpigramsHooks.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query'; +import getEpigrams from '@/apis/getEpigrams'; +import { GetEpigramsResponseType } from '@/schema/epigrams'; + +const useEpigrams = (query: string, page: number, limit: number = 10) => + useQuery({ + queryKey: ['epigrams', query, page, limit], + queryFn: () => getEpigrams({ keyword: query, limit, cursor: page * limit }), + enabled: !!query, + staleTime: 5 * 60 * 1000, // 데이터 신선도 설정 + }); + +export default useEpigrams; diff --git a/src/hooks/useKakaoLogin.ts b/src/hooks/useKakaoLogin.ts new file mode 100644 index 00000000..14d8d81b --- /dev/null +++ b/src/hooks/useKakaoLogin.ts @@ -0,0 +1,42 @@ +import postOauth from '@/apis/oauth'; +import { toast } from '@/components/ui/use-toast'; +import { useMutation } from '@tanstack/react-query'; +import { isAxiosError } from 'axios'; +import { useRouter } from 'next/router'; + +const useKakaoLogin = () => { + const router = useRouter(); + + return useMutation({ + mutationFn: async (code: string) => { + const result = await postOauth(code); + localStorage.setItem('accessToken', result.accessToken); + localStorage.setItem('refreshToken', result.refreshToken); + return result; + }, + onSuccess: () => { + router.push('/'); + }, + onError: (error) => { + if (isAxiosError(error)) { + const status = error.response?.status; + + if (!status) return; + + if (status === 400) { + toast({ description: '잘못된 요청입니다. 요청을 확인해 주세요.', className: 'bg-state-error text-white font-semibold' }); + router.push('/auth/SignIn'); + return; + } + + if (status >= 500) { + toast({ description: '서버에 문제가 발생했습니다. 잠시 후 다시 시도해 주세요.', className: 'bg-state-error text-white font-semibold' }); + } + } + + toast({ description: '알 수 없는 에러가 발생했습니다.', className: 'bg-state-error text-white font-semibold' }); + }, + }); +}; + +export default useKakaoLogin; diff --git a/src/hooks/useMediaQuery.ts b/src/hooks/useMediaQuery.ts new file mode 100644 index 00000000..899c0fce --- /dev/null +++ b/src/hooks/useMediaQuery.ts @@ -0,0 +1,19 @@ +import { useState, useEffect } from 'react'; + +function useMediaQuery(query: string): boolean { + const [matches, setMatches] = useState(false); + + useEffect(() => { + const media = window.matchMedia(query); + if (media.matches !== matches) { + setMatches(media.matches); + } + const listener = () => setMatches(media.matches); + media.addEventListener('change', listener); + return () => media.removeEventListener('change', listener); + }, [matches, query]); + + return matches; +} + +export default useMediaQuery; diff --git a/src/hooks/usePatchCommentHook.ts b/src/hooks/usePatchCommentHook.ts new file mode 100644 index 00000000..b215624d --- /dev/null +++ b/src/hooks/usePatchCommentHook.ts @@ -0,0 +1,32 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { patchComment } from '@/apis/epigramComment'; +import { PatchCommentRequest } from '@/types/epigram.types'; +import { toast } from '@/components/ui/use-toast'; + +const usePatchCommentMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ commentId, ...commentData }: { commentId: number } & PatchCommentRequest) => patchComment(commentId, commentData), + onSuccess: () => { + // 댓글 목록 쿼리 무효화 + queryClient.invalidateQueries({ queryKey: ['epigramComments'] }); + + // 성공 메시지 표시 + toast({ + title: '댓글 수정 성공', + description: '댓글이 성공적으로 수정되었습니다.', + }); + }, + onError: (error) => { + // 에러 메시지 표시 + toast({ + title: '댓글 수정 실패', + description: `댓글 수정 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`, + variant: 'destructive', + }); + }, + }); +}; + +export default usePatchCommentMutation; diff --git a/src/hooks/usePostEmotion.ts b/src/hooks/usePostEmotion.ts new file mode 100644 index 00000000..f13540ae --- /dev/null +++ b/src/hooks/usePostEmotion.ts @@ -0,0 +1,11 @@ +import { useMutation } from '@tanstack/react-query'; +import postEmotion from '@/apis/postEmotion'; +import { EmotionType } from '@/types/emotion'; +import { PostEmotionResponseType } from '@/schema/emotion'; + +const usePostEmotion = () => + useMutation({ + mutationFn: postEmotion, + }); + +export default usePostEmotion; diff --git a/src/hooks/useRegisterMutation.ts b/src/hooks/useRegisterMutation.ts new file mode 100644 index 00000000..8cbe5fe7 --- /dev/null +++ b/src/hooks/useRegisterMutation.ts @@ -0,0 +1,77 @@ +import { postSignup } from '@/apis/auth'; +import { toast } from '@/components/ui/use-toast'; +import { useMutation } from '@tanstack/react-query'; +import { useRouter } from 'next/router'; +import { isAxiosError } from 'axios'; + +const useRegisterMutation = () => { + const router = useRouter(); + + return useMutation({ + mutationFn: postSignup, + onSuccess: (data) => { + localStorage.setItem('accessToken', data.accessToken); + localStorage.setItem('refreshToken', data.refreshToken); + router.push('/'); + }, + onError: (error) => { + if (isAxiosError(error)) { + const { status, data } = error.response || {}; + + if (!status) return; + + if (status === 400) { + const errorMessage = data?.message || '잘못된 요청입니다. 입력 값을 확인해주세요.'; + + if (errorMessage.includes('이미 사용중인 이메일')) { + toast({ + description: '이미 사용중인 이메일입니다.', + className: 'border-state-error text-state-error font-semibold', + }); + return; + } + + toast({ + description: errorMessage, + className: 'border-state-error text-state-error font-semibold', + }); + return; + } + + if (status === 500) { + const errorMessage = data?.message || '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'; + + // NOTE: swagger 문서에서 중복된 닉네임은 500에러와 함께 "Internal Server Error" 메시지로 응답 옴 + if (errorMessage.includes('Internal Server Error')) { + toast({ + description: '이미 존재하는 닉네임입니다.', + className: 'border-state-error text-state-error font-semibold', + }); + return; + } + + toast({ + description: errorMessage, + className: 'border-state-error text-state-error font-semibold', + }); + return; + } + + if (status >= 500) { + toast({ + description: '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + className: 'border-state-error text-state-error font-semibold', + }); + return; + } + + toast({ + description: '알 수 없는 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + className: 'border-state-error text-state-error font-semibold', + }); + } + }, + }); +}; + +export default useRegisterMutation; diff --git a/src/hooks/useSignInMutation.ts b/src/hooks/useSignInMutation.ts new file mode 100644 index 00000000..2f4ebb5e --- /dev/null +++ b/src/hooks/useSignInMutation.ts @@ -0,0 +1,22 @@ +import { postSignin } from '@/apis/auth'; +import { toast } from '@/components/ui/use-toast'; +import { useMutation } from '@tanstack/react-query'; +import { useRouter } from 'next/router'; + +const useSigninMutation = () => { + const router = useRouter(); + + return useMutation({ + mutationFn: postSignin, + onSuccess: (data) => { + localStorage.setItem('accessToken', data.accessToken); + localStorage.setItem('refreshToken', data.refreshToken); + router.push('/'); + }, + onError: () => { + toast({ description: '이메일 혹은 비밀번호를 확인해주세요.', className: 'border-state-error text-state-error font-semibold' }); + }, + }); +}; + +export default useSigninMutation; diff --git a/src/hooks/useTagManagementHook.ts b/src/hooks/useTagManagementHook.ts new file mode 100644 index 00000000..dd0082de --- /dev/null +++ b/src/hooks/useTagManagementHook.ts @@ -0,0 +1,47 @@ +import { useState } from 'react'; +import { UseFormSetValue, UseFormGetValues, UseFormSetError } from 'react-hook-form'; +import { AddEpigramFormType } from '@/schema/addEpigram'; + +// NOTE: setError메서드로 FormField에 에러 설정 가능 +const useTagManagement = ({ + setValue, + getValues, + setError, +}: { + setValue: UseFormSetValue; + getValues: UseFormGetValues; + setError: UseFormSetError; +}) => { + const [currentTag, setCurrentTag] = useState(''); + + const handleAddTag = () => { + if (!currentTag || currentTag.length > 10) { + return; + } + const currentTags = getValues('tags') || []; + + if (currentTags.length >= 3) { + return; + } + if (currentTags.includes(currentTag)) { + setError('tags', { type: 'manual', message: '이미 저장된 태그입니다.' }); + return; + } + + setValue('tags', [...currentTags, currentTag]); + setCurrentTag(''); + setError('tags', { type: 'manual', message: '' }); + }; + + const handleRemoveTag = (tagToRemove: string) => { + const currentTags = getValues('tags') || []; + setValue( + 'tags', + currentTags.filter((tag) => tag !== tagToRemove), + ); + }; + + return { currentTag, setCurrentTag, handleAddTag, handleRemoveTag }; +}; + +export default useTagManagement; diff --git a/src/hooks/userQueryHooks.ts b/src/hooks/userQueryHooks.ts index 7c28fe75..f5043b15 100644 --- a/src/hooks/userQueryHooks.ts +++ b/src/hooks/userQueryHooks.ts @@ -1,6 +1,6 @@ import quries from '@/apis/queries'; -import { updateMe } from '@/apis/user'; -import { GetUserReponseType, GetUserRequestType, PatchMeRequestType } from '@/schema/user'; +import { updateMe, createPresignedUrl } from '@/apis/user'; +import { GetUserRequestType, PatchMeRequestType, PostPresignedUrlRequestType, PostPresignedUrlResponseType } from '@/schema/user'; import { MutationOptions } from '@/types/query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; @@ -8,7 +8,7 @@ export const useMeQuery = () => useQuery(quries.user.getMe()); export const useUserQuery = (requset: GetUserRequestType) => useQuery(quries.user.getUser(requset)); -export const useUpdateMe = (options: MutationOptions) => { +export const useUpdateMe = (options: MutationOptions) => { const queryClient = useQueryClient(); return useMutation({ mutationFn: (request: PatchMeRequestType) => updateMe(request), @@ -21,3 +21,13 @@ export const useUpdateMe = (options: MutationOptions) => { }, }); }; + +// presignedUrl 생성 +export const useCreatePresignedUrl = (options?: MutationOptions) => + useMutation({ + mutationFn: (request: PostPresignedUrlRequestType) => createPresignedUrl(request), + ...options, + onSuccess: (data: PostPresignedUrlResponseType) => + // 이미지 URL 반환 + data.url, + }); diff --git a/src/pageLayout/Epigram/AddEpigram.tsx b/src/pageLayout/Epigram/AddEpigram.tsx new file mode 100644 index 00000000..f314c730 --- /dev/null +++ b/src/pageLayout/Epigram/AddEpigram.tsx @@ -0,0 +1,316 @@ +import React, { KeyboardEvent, useCallback, useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import Header from '@/components/Header/Header'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { Textarea } from '@/components/ui/textarea'; +import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form'; +import { AddEpigramFormSchema, AddEpigramFormType } from '@/schema/addEpigram'; +import useAddEpigram from '@/hooks/epigramQueryHook'; +import { useRouter } from 'next/router'; +import { AlertDialog, AlertDialogAction, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'; +import useTagManagement from '@/hooks/useTagManagementHook'; +import { useMeQuery } from '@/hooks/userQueryHooks'; + +function AddEpigram() { + const router = useRouter(); + const { data: userData, isPending, isError } = useMeQuery(); + const [isAlertOpen, setIsAlertOpen] = useState(false); + const [alertContent, setAlertContent] = useState({ title: '', description: '' }); + const [selectedAuthorOption, setSelectedAuthorOption] = useState('directly'); // 기본값을 'directly'로 설정 + const [isFormValid, setIsFormValid] = useState(false); + + const form = useForm({ + resolver: zodResolver(AddEpigramFormSchema), + defaultValues: { + content: '', + author: '', + referenceTitle: '', + referenceUrl: '', + tags: [], + }, + }); + + // NOTE: 필수항목들에 값이 들어있는지 확인 함수 + const checkFormEmpty = useCallback(() => { + const { content, author, tags } = form.getValues(); + return content.trim() !== '' && author.trim() !== '' && tags.length > 0; + }, [form]); + + // NOTE: form값이 변경될때 필수항목들이 들어있는지 확인 + const watchForm = useCallback(() => { + setIsFormValid(checkFormEmpty()); + }, [checkFormEmpty]); + + useEffect(() => { + const subscription = form.watch(watchForm); + return () => subscription.unsubscribe(); + }, [form, watchForm]); + + const { currentTag, setCurrentTag, handleAddTag, handleRemoveTag } = useTagManagement({ + setValue: form.setValue, + getValues: form.getValues, + setError: form.setError, + }); + const addEpigramMutation = useAddEpigram({ + onSuccess: () => { + setAlertContent({ + title: '등록 완료', + description: '등록이 완료되었습니다.', + }); + setIsAlertOpen(true); + form.reset(); + }, + onError: () => { + setAlertContent({ + title: '등록 실패', + description: '다시 시도해주세요.', + }); + setIsAlertOpen(true); + }, + }); + + const handleAlertClose = () => { + setIsAlertOpen(false); + if (alertContent.title === '등록 완료') { + router.push(`/epigram/${addEpigramMutation.data?.id}`); + } + }; + + const AUTHOR_OPTIONS = [ + { value: 'directly', label: '직접 입력' }, + { value: 'unknown', label: '알 수 없음' }, + { value: 'me', label: '본인' }, + ]; + + // NOTE: default를 직접 입력으로 설정 + // NOTE: 본인을 선택 시 유저의 nickname이 들어감 + const handleAuthorChange = async (value: string) => { + setSelectedAuthorOption(value); + let authorValue: string; + + switch (value) { + case 'unknown': + authorValue = '알 수 없음'; + break; + case 'me': + if (isPending) { + authorValue = '로딩 중...'; + } else if (userData) { + authorValue = userData.nickname; + } else { + authorValue = '본인 (정보 없음)'; + } + break; + default: + authorValue = ''; + } + form.setValue('author', authorValue); + }; + + if (isPending) { + return 사용자 정보를 불러오는 중...; + } + + if (isError) { + return 사용자 정보를 불러오는 데 실패했습니다. 페이지를 새로고침 해주세요.; + } + + // NOTE: 태그를 저장하려고 할때 enter키를 누르면 폼제출이 되는걸 방지 + const handleKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddTag(); + } + }; + + // NOTE: url와title은 필수 항목이 아니라서 빈칸으로 제출할 때 항목에서 제외 + const handleSubmit = (data: AddEpigramFormType) => { + const submitData = { ...data }; + + if (!submitData.referenceUrl) { + delete submitData.referenceUrl; + } + + if (!submitData.referenceTitle) { + delete submitData.referenceTitle; + } + + addEpigramMutation.mutate(submitData); + }; + + return ( + <> + {}} /> + + + + ( + + + 내용 + * + + + + + + + )} + /> + + + 저자 + * + + + + {AUTHOR_OPTIONS.map((option) => ( + + + + {option.label} + + + ))} + + + + ( + + + {/* NOTE: 직접 입력 radio버튼을 선택하지않으면 수정 불가 */} + + + + + )} + /> + + 출처 + ( + + + + + + + )} + /> + ( + + + + + + + )} + /> + + ( + + + 태그 + * + + + { + setCurrentTag(e.target.value); + form.clearErrors('tags'); + }} + onKeyUp={handleKeyUp} + maxLength={10} + /> + + = 3 || currentTag.length === 0} + > + 저장 + + + + {/* NOTE: 태그의 키값을 변경하는 대신 중복된 태그를 저장 못하게 설정 */} + + {field.value.map((tag) => ( + + {tag} + handleRemoveTag(tag)}> + × + + + ))} + + + )} + /> + {/* NOTE: 필수항목들에 값이 채워져있으면 폼제출 버튼 활성화 */} + + {addEpigramMutation.isPending ? '제출 중...' : '작성 완료'} + + + + + + + + + {alertContent.title} + {alertContent.description} + + + 확인 + + + + > + ); +} + +export default AddEpigram; diff --git a/src/pageLayout/MypageLayout/MyPageLayout.tsx b/src/pageLayout/MypageLayout/MyPageLayout.tsx new file mode 100644 index 00000000..12627ade --- /dev/null +++ b/src/pageLayout/MypageLayout/MyPageLayout.tsx @@ -0,0 +1,47 @@ +import Header from '@/components/Header/Header'; +import { useMeQuery } from '@/hooks/userQueryHooks'; +import UserInfo from '@/types/user'; +import EmotionMonthlyLogs from '@/user/ui-profile/EmotionMonthlyLogs'; +import Profile from '@/user/ui-profile/Profile'; +import { useRouter } from 'next/navigation'; + +export default function MyPageLayout() { + const { data, isLoading, isError }: { data: UserInfo | undefined; isLoading: boolean; isError: boolean } = useMeQuery(); + + const router = useRouter(); + + if (isError) { + return error; + } + + if (isLoading) { + return loading; + } + + // NOTE: 회원정보가 확인되지 않는다면 로그인 페이지로 이동 + if (!data) { + router.push('/login'); + return false; + } + + return ( + + {}} /> + + + + 오늘의 감정 + + + + + + 내 에피그램(19) + 내 댓글(110) + + 댓글 컴포넌트 + + + + ); +} diff --git a/src/pageLayout/SearchLayout/SearchLayout.tsx b/src/pageLayout/SearchLayout/SearchLayout.tsx new file mode 100644 index 00000000..02c68db9 --- /dev/null +++ b/src/pageLayout/SearchLayout/SearchLayout.tsx @@ -0,0 +1,114 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { useRouter } from 'next/router'; +import Header from '@/components/Header/Header'; +import SearchBar from '@/components/search/SearchBar'; +import RecentSearches from '@/components/search/RecentSearches'; +import SearchResults from '@/components/search/SearchResults'; +import useEpigrams from '@/hooks/useGetEpigramsHooks'; +import { GetEpigramsResponseType } from '@/schema/epigrams'; + +function SearchLayout() { + const [searches, setSearches] = useState([]); + const [currentSearch, setCurrentSearch] = useState(''); + const [page, setPage] = useState(0); + const [allResults, setAllResults] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [nextCursor, setNextCursor] = useState(null); + const observerRef = useRef(null); + const loadMoreRef = useRef(null); + const router = useRouter(); + + const isBrowser = typeof window !== 'undefined'; // 브라우저 환경에서만 localStorage에 접근 + const accessToken = isBrowser ? localStorage.getItem('accessToken') : null; + const isUserLoggedIn = !!accessToken; + const userId = isUserLoggedIn ? 'loggedInUser' : 'guest'; // 사용자 ID를 기반으로 저장 키 생성 + const recentSearchesKey = `recentSearches_${userId}`; + + const { data: searchResults, isLoading } = useEpigrams(currentSearch, page); + + // 새로운 검색 결과를 allResults에 누적, 총 결과 개수와 다음 커서를 업데이트 + useEffect(() => { + if (searchResults?.list) { + setAllResults((prevResults) => [...prevResults, ...searchResults.list]); + setTotalCount(searchResults.totalCount); + setNextCursor(searchResults.nextCursor); + } + }, [searchResults]); + + // observerRef가 화면에 나타날 때 페이지 증가,추가 데이터 로드 + useEffect(() => { + if (observerRef.current) observerRef.current.disconnect(); + + observerRef.current = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && !isLoading && nextCursor !== null) { + setPage((prevPage) => prevPage + 1); + } + }); + + if (loadMoreRef.current) { + observerRef.current.observe(loadMoreRef.current); + } + + // 옵저버 클린업 (메모리 누수 방지) + return () => { + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; + } + }; + }, [allResults.length, isLoading, nextCursor]); + + // 컴포넌트가 처음 렌더링 될 때 저장된 최근 검색어 불러오기, 로그인된 사용자 별로 최근 검색어를 구분하여 URL에 데이터 저장 + useEffect(() => { + if (isBrowser) { + const storedSearches = JSON.parse(localStorage.getItem(recentSearchesKey) || '[]'); + setSearches(storedSearches); + } + + const searchParams = new URLSearchParams(window.location.search); + const query = searchParams.get('q'); + if (query) { + setCurrentSearch(query); + } + }, [recentSearchesKey]); + + // 모두지우기 클릭 시 저장된 최근 검색어 삭제 + const handleClearAll = () => { + setSearches([]); + if (isBrowser) { + localStorage.removeItem(recentSearchesKey); + } + }; + + // 검색어가 제출될 때 작동 + const handleSearch = (search: string) => { + setPage(0); + setAllResults([]); + setSearches((prevSearches) => { + const updatedSearches = [search, ...prevSearches.filter((item) => item !== search)].slice(0, 10); + if (isBrowser) { + localStorage.setItem(recentSearchesKey, JSON.stringify(updatedSearches)); + } + return updatedSearches; + }); + setCurrentSearch(search); + + const searchParams = new URLSearchParams(window.location.search); + searchParams.set('q', search); + router.push(`/search?${searchParams.toString()}`); + }; + + return ( + <> + {}} />; + + + + {currentSearch && } + + + > + ); +} + +export default SearchLayout; diff --git a/src/pageLayout/index.ts b/src/pageLayout/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 37d2f8d3..107acf01 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -3,6 +3,7 @@ import '@/styles/globals.css'; import type { AppProps } from 'next/app'; import { HydrationBoundary, QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import Toaster from '@/components/ui/toaster'; export default function App({ Component, pageProps }: AppProps) { const [queryClient] = React.useState(() => new QueryClient()); @@ -10,6 +11,7 @@ export default function App({ Component, pageProps }: AppProps) { + diff --git a/src/pages/addEpigram.tsx b/src/pages/addEpigram.tsx new file mode 100644 index 00000000..28e1645e --- /dev/null +++ b/src/pages/addEpigram.tsx @@ -0,0 +1,7 @@ +import AddEpigram from '@/pageLayout/Epigram/AddEpigram'; + +function Add() { + return ; +} + +export default Add; diff --git a/src/pages/auth/SignIn.tsx b/src/pages/auth/SignIn.tsx new file mode 100644 index 00000000..c1bfd025 --- /dev/null +++ b/src/pages/auth/SignIn.tsx @@ -0,0 +1,99 @@ +import Image from 'next/image'; +import Link from 'next/link'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form'; +import { PostSigninRequest, PostSigninRequestType } from '@/schema/auth'; +import useSigninMutation from '@/hooks/useSignInMutation'; + +export default function SignIn() { + const mutationSignin = useSigninMutation(); + // 폼 정의 + const form = useForm({ + resolver: zodResolver(PostSigninRequest), + mode: 'onBlur', + defaultValues: { + email: '', + password: '', + }, + }); + + // TODO: 나중에 컴포넌트 분리하기 + return ( + + + + + + + + mutationSignin.mutate(values))} className='flex flex-col items-center lg:gap-6 gap-5 w-full px-6'> + + ( + + + + + + + )} + /> + ( + + + + + + + )} + /> + + + 로그인 + + + + + 회원이 아니신가요? + + + 가입하기 + + + + + + + + + + + + + + + + ); +} diff --git a/src/pages/auth/SignUp.tsx b/src/pages/auth/SignUp.tsx new file mode 100644 index 00000000..e6c52906 --- /dev/null +++ b/src/pages/auth/SignUp.tsx @@ -0,0 +1,129 @@ +import Image from 'next/image'; +import Link from 'next/link'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { PostSignUpRequest, PostSignUpRequestType } from '@/schema/auth'; +import { useForm } from 'react-hook-form'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import useRegisterMutation from '@/hooks/useRegisterMutation'; + +export default function SignUp() { + const mutationRegister = useRegisterMutation(); + + const form = useForm({ + resolver: zodResolver(PostSignUpRequest), + mode: 'onBlur', + defaultValues: { + email: '', + password: '', + passwordConfirmation: '', + nickname: '', + }, + }); + + return ( + + + + + + + + + mutationRegister.mutate(values))} className='flex flex-col items-center w-full h-full px-6'> + ( + + 이메일 + + + + + + )} + /> + ( + + 비밀번호 + + + + + + )} + /> + ( + + + + + + + )} + /> + ( + + 닉네임 + + + + + + )} + /> + + 가입하기 + + + + + + + + + + + + + + + + + ); +} diff --git a/src/pages/auth/redirect/kakao/index.tsx b/src/pages/auth/redirect/kakao/index.tsx new file mode 100644 index 00000000..46e82db7 --- /dev/null +++ b/src/pages/auth/redirect/kakao/index.tsx @@ -0,0 +1,18 @@ +import useKakaoLogin from '@/hooks/useKakaoLogin'; +import { useSearchParams } from 'next/navigation'; +import { useEffect } from 'react'; + +export default function Kakao() { + const searchParams = useSearchParams(); + const code = searchParams.get('code'); + const { mutate: login } = useKakaoLogin(); + + useEffect(() => { + if (code) { + login(code); + } else { + /* eslint-disable no-console */ + console.log(code); // code가 없을 때 콘솔에 출력 + } + }, [code, login]); +} diff --git a/src/pages/mypage/index.tsx b/src/pages/mypage/index.tsx new file mode 100644 index 00000000..69b8c83e --- /dev/null +++ b/src/pages/mypage/index.tsx @@ -0,0 +1,5 @@ +import MyPageLayout from '@/pageLayout/MypageLayout/MyPageLayout'; + +export default function mypage() { + return ; +} diff --git a/src/pages/search/index.tsx b/src/pages/search/index.tsx new file mode 100644 index 00000000..0914e7b7 --- /dev/null +++ b/src/pages/search/index.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import SearchLayout from '@/pageLayout/SearchLayout/SearchLayout'; + +function Search() { + return ; +} + +export default Search; diff --git a/src/schema/addEpigram.ts b/src/schema/addEpigram.ts new file mode 100644 index 00000000..d12285b1 --- /dev/null +++ b/src/schema/addEpigram.ts @@ -0,0 +1,44 @@ +import { z } from 'zod'; + +const urlRegex = /^https?:\/\/.+/; + +export const AddEpigramRequestSchema = z.object({ + tags: z.array(z.string().min(1).max(10)).max(3), + referenceUrl: z.string().url().regex(urlRegex).optional().nullable(), + referenceTitle: z.string().max(100).optional().nullable(), + author: z.string().min(1).max(30), + content: z.string().min(1).max(500), +}); + +export const AddEpigramResponseSchema = z.object({ + likeCount: z.number(), + tags: z.array( + z.object({ + name: z.string().min(1).max(10), + id: z.number().int().positive(), + }), + ), + writerId: z.number().int().positive(), + referenceUrl: z.string().url().regex(urlRegex).nullable(), + referenceTitle: z.string().max(100).nullable(), + author: z.string().min(1).max(30), + content: z.string().min(1).max(500), + id: z.number().int().positive(), +}); + +export const AddEpigramFormSchema = z + .object({ + tags: z.array(z.string().min(1).max(10)).min(1, { message: '최소 1개의 태그를 추가해주세요.' }).max(3), + author: z.string().min(1, { message: '저자의 이름을 입력해주세요' }).max(30, { message: '30자 이내로 입력해주세요.' }), + content: z.string().min(1, { message: '내용을 입력해주세요.' }).max(500, { message: '500자 이내로 입력해주세요.' }), + referenceUrl: z.union([z.string().regex(urlRegex, { message: '올바른 URL 형식이 아닙니다.' }), z.literal('')]).optional(), + referenceTitle: z.union([z.string().max(100, { message: '100자 이내로 입력해주세요.' }), z.literal('')]).optional(), + }) + .refine((data) => (data.referenceUrl === '' && data.referenceTitle === '') || (data.referenceUrl !== '' && data.referenceTitle !== ''), { + message: 'URL과 제목을 모두 입력하거나 모두 비워주세요.', + path: ['referenceUrl', 'referenceTitle'], + }); + +export type AddEpigramRequestType = z.infer; +export type AddEpigramResponseType = z.infer; +export type AddEpigramFormType = z.infer; diff --git a/src/schema/auth.ts b/src/schema/auth.ts new file mode 100644 index 00000000..5de73332 --- /dev/null +++ b/src/schema/auth.ts @@ -0,0 +1,50 @@ +import * as z from 'zod'; + +const PWD_VALIDATION_REGEX = /^(?=.*[a-zA-Z])(?=.*[0-9])(?=.*[!@#$%^&*])[a-zA-Z0-9!@#$%^&*]{8,}$/; + +// NOTE: 회원가입 스키마 +export const PostSignUpRequest = z + .object({ + email: z.string().min(1, { message: '이메일은 필수 입력입니다.' }).email({ message: '이메일 형식으로 작성해 주세요.' }), + password: z + .string() + .min(1, { message: '비밀번호는 필수 입력입니다.' }) + .min(8, { message: '비밀번호는 최소 8자 이상입니다.' }) + .regex(PWD_VALIDATION_REGEX, { message: '비밀번호는 숫자, 영문, 특수문자로만 가능합니다.' }), + passwordConfirmation: z.string().min(1, { message: '비밀번호 확인을 입력해주세요.' }), + nickname: z.string().min(1, { message: '닉네임은 필수 입력입니다.' }).max(20, { message: '닉네임은 최대 20자까지 가능합니다.' }), + }) + .refine((data) => data.password === data.passwordConfirmation, { + message: '비밀번호가 일치하지 않습니다.', + path: ['passwordConfirmation'], + }); + +// NOTE: 로그인 스키마 + +export const PostSigninRequest = z.object({ + email: z.string().min(1, { message: '이메일은 필수 입력입니다.' }).email({ message: '올바른 이메일 주소가 아닙니다.' }), + password: z.string().min(1, { message: '비밀번호는 필수 입력입니다.' }), +}); + +const User = z.object({ + id: z.number(), + email: z.string().email(), + nickname: z.string(), + teamId: z.string(), + updatedAt: z.coerce.date(), + createdAt: z.coerce.date(), + image: z.string(), +}); + +export const PostAuthResponse = z.object({ + accessToken: z.string(), + refreshToken: z.string(), + user: User, +}); + +// NOTE: 회원가입 타입 +export type PostSignUpRequestType = z.infer; +export type PostSignUpResponseType = z.infer; +// NOTE: 로그인 타입 +export type PostSigninRequestType = z.infer; +export type PostSigninResponseType = z.infer; diff --git a/src/schema/comment.ts b/src/schema/comment.ts new file mode 100644 index 00000000..391557df --- /dev/null +++ b/src/schema/comment.ts @@ -0,0 +1,44 @@ +import { z } from 'zod'; + +const WriterSchema = z.object({ + image: z.string().nullable(), + nickname: z.string(), + id: z.number(), +}); + +const CommentContentSchema = z.string().min(1); + +const CommentSchema = z.object({ + epigramId: z.number(), + writer: WriterSchema, + updatedAt: z.string().datetime(), + createdAt: z.string().datetime(), + isPrivate: z.boolean(), + content: CommentContentSchema, + id: z.number(), +}); + +const CommentResponseSchema = z.object({ + totalCount: z.number(), + nextCursor: z.number().nullable(), + list: z.array(CommentSchema), +}); + +const CommentRequestSchema = z.object({ + id: z.number().int().positive(), + limit: z.number().int().positive().max(100), + cursor: z.number().optional(), +}); + +const CommentFormSchema = z.object({ + content: z.string().min(1, '댓글을 입력해주세요.').max(100, '100자 이내로 입력해주세요.'), + isPrivate: z.boolean().default(true), +}); + +export type CommentFormValues = z.infer; +export type CommentRequestType = z.infer; +export type CommentResponseType = z.infer; +export type CommentType = z.infer; +export type Writer = z.infer; + +export { CommentRequestSchema, CommentResponseSchema, CommentFormSchema, CommentSchema, WriterSchema }; diff --git a/src/schema/emotion.ts b/src/schema/emotion.ts new file mode 100644 index 00000000..35dc2f35 --- /dev/null +++ b/src/schema/emotion.ts @@ -0,0 +1,43 @@ +import * as z from 'zod'; + +/** **************** 감정 달력 ***************** */ +export const GetMonthlyEmotionLogsRequest = z.object({ + userId: z.number(), + year: z.number(), + month: z.number(), +}); + +// 감정 로그 항목의 스키마 정의 +const EmotionSchema = z.object({ + id: z.number(), + userId: z.number(), + emotion: z.enum(['MOVED', 'HAPPY', 'WORRIED', 'SAD', 'ANGRY']), + createdAt: z.coerce.date(), +}); + +// 감정 로그 배열 정의 +export const GetMonthlyEmotionLogsResponse = z.array(EmotionSchema); +export type GetMonthlyEmotionLogsRequestType = z.infer; +export type GetMonthlyEmotionLogsResponseType = z.infer; + +export const PostEmotionRequest = z.object({ + emotion: z.enum(['MOVED', 'HAPPY', 'WORRIED', 'SAD', 'ANGRY']), +}); + +export const PostEmotionResponse = z.object({ + createdAt: z.coerce.date(), + emotion: z.enum(['MOVED', 'HAPPY', 'WORRIED', 'SAD', 'ANGRY']), + userId: z.number(), + id: z.number(), +}); + +export const GetEmotionResponse = z.object({ + createdAt: z.coerce.date(), + emotion: z.enum(['MOVED', 'HAPPY', 'WORRIED', 'SAD', 'ANGRY']), + userId: z.number(), + id: z.number(), +}); + +export type PostEmotionRequestType = z.infer; +export type PostEmotionResponseType = z.infer; +export type GetEmotionResponseType = z.infer; diff --git a/src/schema/epigram.ts b/src/schema/epigram.ts new file mode 100644 index 00000000..f72fdec7 --- /dev/null +++ b/src/schema/epigram.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +// Tag 스키마 +const TagSchema = z.object({ + name: z.string().min(1).max(10), + id: z.number().int().positive(), +}); + +// GetEpigramResponseType 스키마 +const GetEpigramResponseSchema = z.object({ + id: z.number().int().positive(), + content: z.string().min(1).max(500), + author: z.string().min(1).max(30), + referenceTitle: z.string().max(100).nullable().optional(), + referenceUrl: z.string().url().nullable().optional(), + writerId: z.number().int().positive(), + tags: z.array(TagSchema), + likeCount: z.number(), + isLiked: z.boolean().optional(), +}); + +const EpigramRequestSchema = z.object({ + id: z.union([z.string(), z.number(), z.undefined()]), +}); + +export type Tag = z.infer; +export type GetEpigramResponseType = z.infer; +export type EpigramRequestType = z.infer; + +export { TagSchema, GetEpigramResponseSchema, EpigramRequestSchema }; diff --git a/src/schema/epigrams.ts b/src/schema/epigrams.ts new file mode 100644 index 00000000..46a7cb85 --- /dev/null +++ b/src/schema/epigrams.ts @@ -0,0 +1,33 @@ +import * as z from 'zod'; + +export const GetEpigramsParams = z.object({ + limit: z.number(), + cursor: z.number().optional(), + keyword: z.string().optional(), + writerId: z.number().optional(), +}); + +export const GetEpigramsResponse = z.object({ + totalCount: z.number(), + nextCursor: z.number(), + list: z.array( + z.object({ + likeCount: z.number(), + tags: z.array( + z.object({ + name: z.string(), + id: z.number(), + }), + ), + writerId: z.number(), + referenceUrl: z.string(), + referenceTitle: z.string(), + author: z.string(), + content: z.string(), + id: z.number(), + }), + ), +}); + +export type GetEpigramsParamsType = z.infer; +export type GetEpigramsResponseType = z.infer; diff --git a/src/schema/user.ts b/src/schema/user.ts index 83d1f8d8..5c6881cb 100644 --- a/src/schema/user.ts +++ b/src/schema/user.ts @@ -1,4 +1,5 @@ import * as z from 'zod'; +import { MAX_FILE_SIZE, ACCEPTED_IMAGE_TYPES } from '@/user/utill/constants'; export const PatchMeRequest = z.object({ image: z.string().url(), @@ -18,6 +19,20 @@ export const GetUserReponse = z.object({ id: z.number(), }); -export type GetUserReponseType = z.infer; +const PostPresignedUrlRequest = z.object({ + image: z + .instanceof(File) + .refine((file) => file.size <= MAX_FILE_SIZE, `업로드 파일의 용량은 최대 ${MAX_FILE_SIZE / (1024 * 1024)}MB 입니다.`) + .refine((file) => ACCEPTED_IMAGE_TYPES.includes(file.type), '.jpg, .jpeg, .png 확장자만 업로드 가능합니다.'), +}); + +export const PostPresignedUrlResponse = z.object({ + url: z.string().url(), +}); + +export type GetUserResponseType = z.infer; export type GetUserRequestType = z.infer; export type PatchMeRequestType = z.infer; + +export type PostPresignedUrlRequestType = z.infer; +export type PostPresignedUrlResponseType = z.infer; diff --git a/src/styles/CommentCardStyles.ts b/src/styles/CommentCardStyles.ts new file mode 100644 index 00000000..6d7a6a66 --- /dev/null +++ b/src/styles/CommentCardStyles.ts @@ -0,0 +1,44 @@ +export const sizeStyles = { + sm: 'w-[360px] h-[130px]', + md: 'md:w-[384px] md:h-[162px]', + lg: 'lg:w-[640px] lg:h-[176px]', +}; + +export const textSizeStyles = { + sm: { + name: 'text-xs', + time: 'text-xs', + action: 'text-xs', + content: 'text-sm', + }, + md: { + name: 'md:text-sm', + time: 'md:text-sm', + action: 'md:text-sm', + content: 'md:text-base', + }, + lg: { + name: 'lg:text-base', + time: 'lg:text-base', + action: 'lg:text-base', + content: 'lg:text-xl', + }, +}; + +export const gapStyles = { + sm: 'gap-2', + md: 'md:gap-3', + lg: 'lg:gap-4', +}; + +export const paddingStyles = { + sm: 'py-4 px-6', // 위아래 16px, 양 옆 24px + md: 'md:p-6', // 위아래, 양옆 24px + lg: 'lg:py-[35px] lg:px-6', // 위아래 35px, 양 옆 24px +}; + +export const contentWidthStyles = { + sm: 'w-[248px]', + md: 'md:w-[272px]', + lg: 'lg:w-[528px]', +}; diff --git a/src/types/emotion.ts b/src/types/emotion.ts new file mode 100644 index 00000000..45b5b78c --- /dev/null +++ b/src/types/emotion.ts @@ -0,0 +1,30 @@ +export interface Emotion { + userId: number; + year: number; + month: number; +} + +// 감정 로그 타입 지정 +export type EmotionTypeEN = 'MOVED' | 'HAPPY' | 'WORRIED' | 'SAD' | 'ANGRY'; + +export interface EmotionLog { + id: number; + userId: number; + emotion: EmotionTypeEN; + createdAt: Date; +} + +export type EmotionType = '감동' | '기쁨' | '고민' | '슬픔' | '분노'; +export type EmotionState = 'Default' | 'Unclicked' | 'Clicked'; + +export interface EmotionIconCardProps { + iconType: EmotionType; // 아이콘 종류 + state: EmotionState; // 상태 + size: 'sm' | 'md' | 'lg'; // 크기 + onClick?: () => void; // 클릭 이벤트 핸들러 +} + +export interface InteractiveEmotionIconCardProps extends Omit { + state: EmotionState; + onClick: () => void; +} diff --git a/src/types/epigram.types.ts b/src/types/epigram.types.ts new file mode 100644 index 00000000..226ee9ee --- /dev/null +++ b/src/types/epigram.types.ts @@ -0,0 +1,24 @@ +import { GetEpigramResponseType } from '@/schema/epigram'; +import { GetUserResponseType } from '@/schema/user'; + +export interface EpigramFigureProps { + epigram: GetEpigramResponseType; + currentUserId: GetUserResponseType['id'] | undefined; +} + +export interface EpigramCommentProps { + epigramId: number; + currentUserId: GetUserResponseType['id'] | undefined; + userImage?: string | undefined; +} + +export interface PostCommentRequest { + epigramId: number; + isPrivate: boolean; + content: string; +} + +export interface PatchCommentRequest { + isPrivate: boolean; + content: string; +} diff --git a/src/types/user.ts b/src/types/user.ts new file mode 100644 index 00000000..d821ed31 --- /dev/null +++ b/src/types/user.ts @@ -0,0 +1,13 @@ +export default interface UserInfo { + nickname: string; + image: string; + id: number; + updatedAt: Date; + createdAt: Date; + teamId: string; +} + +export interface UserProfileProps { + image: string; + nickname: string; +} diff --git a/src/user/ui-profile/Calendar.tsx b/src/user/ui-profile/Calendar.tsx new file mode 100644 index 00000000..4ce6d06a --- /dev/null +++ b/src/user/ui-profile/Calendar.tsx @@ -0,0 +1,96 @@ +import React, { useState } from 'react'; +import Image from 'next/image'; +import { subMonths } from 'date-fns'; +import { EmotionLog, EmotionTypeEN } from '@/types/emotion'; +import useCalendar from '../../hooks/useCalendar'; +import { DAY_LIST, DATE_MONTH_FIXER, iconPaths } from '../utill/constants'; +import CalendarHeader from './CalendarHeader'; + +interface CalendarProps { + currentDate: Date; // 현재 날짜 + setCurrentDate: React.Dispatch>; // 현재 날짜를 설정하는 함수 + monthlyEmotionLogs: EmotionLog[]; +} + +export default function Calendar({ currentDate, setCurrentDate, monthlyEmotionLogs }: CalendarProps) { + // 캘린더 함수 호출 + const { weekCalendarList } = useCalendar(currentDate); + // 감정 필터 + const [selectedEmotion, setSelectedEmotion] = useState(null); + + // 달력에 출력할 수 있게 매핑 + const emotionMap: Record = Array.isArray(monthlyEmotionLogs) + ? monthlyEmotionLogs.reduce>((acc, log) => { + const date = new Date(log.createdAt); + const dateString = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + acc[dateString] = log.emotion as EmotionTypeEN; + return acc; + }, {}) + : {}; + + // 이전 달 클릭 + const handlePrevMonth = () => setCurrentDate((prevDate) => subMonths(prevDate, DATE_MONTH_FIXER)); + // 다음 달 클릭 + const handleNextMonth = () => setCurrentDate((prevDate) => subMonths(prevDate, -DATE_MONTH_FIXER)); + + // 감정 필터 + const handleEmotionSelect = (emotion: EmotionTypeEN) => { + // 현재 선택된 감정과 같으면 초기화 + if (selectedEmotion === emotion) { + setSelectedEmotion(null); + } else { + setSelectedEmotion(emotion); + } + }; + + // 필터링된 감정 맵 생성 + const filteredEmotionMap = selectedEmotion ? Object.fromEntries(Object.entries(emotionMap).filter(([, value]) => value === selectedEmotion)) : emotionMap; + + return ( + + {/* 캘린더 헤더 */} + + {/* 캘린더 */} + + + {DAY_LIST.map((day) => ( + + {day} + + ))} + + {weekCalendarList.map((week, weekIndex) => ( + // TODO: index 값 Lint error. 임시로 주석 사용. 추후 수정 예정 + // eslint-disable-next-line react/no-array-index-key + + {week.map((day, dayIndex) => { + // 현재 날짜와 비교 + const isToday = day === currentDate.getDate() && currentDate.getMonth() === new Date().getMonth() && currentDate.getFullYear() === new Date().getFullYear(); + const dateString = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + const emotion: EmotionTypeEN = filteredEmotionMap[dateString]; // 날짜에 해당하는 감정 가져오기 + const iconPath = emotion && iconPaths[emotion] ? iconPaths[emotion].path : '/icon/BW/SmileFaceBWIcon.svg'; + + return ( + + {emotion ? ( + + {day} + + + ) : ( + {day} + )} + + ); + })} + + ))} + + + ); +} diff --git a/src/user/ui-profile/CalendarHeader.tsx b/src/user/ui-profile/CalendarHeader.tsx new file mode 100644 index 00000000..2c337e1c --- /dev/null +++ b/src/user/ui-profile/CalendarHeader.tsx @@ -0,0 +1,58 @@ +import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuGroup, DropdownMenu } from '@/components/ui/dropdown-menu'; +import { Button } from '@/components/ui/button'; +import Image from 'next/image'; +import { EmotionTypeEN } from '@/types/emotion'; +import ARROW_BOTTOM_ICON from '../../../public/icon/arrow-bottom-icon.svg'; +import ARROW_RIGHT_ICON from '../../../public/icon/arrow-right-icon.svg'; +import ARROW_LEFT_ICON from '../../../public/icon/arrow-left-icon.svg'; +import { iconPaths } from '../utill/constants'; + +interface CalendarHeaderProps { + currentDate: Date; + onPrevMonth: () => void; + onNextMonth: () => void; + onEmotionSelect: (emotion: EmotionTypeEN) => void; + selectEmotion: EmotionTypeEN | null; +} + +export default function CalendarHeader({ currentDate, onPrevMonth, onNextMonth, onEmotionSelect, selectEmotion }: CalendarHeaderProps) { + return ( + + + {`${currentDate.getFullYear()}년 ${currentDate.getMonth() + 1}월`} + + + + 필터: 감동 + + + + + + + + {Object.entries(iconPaths).map(([emotionKey, { path, name }]) => ( + + onEmotionSelect(emotionKey as EmotionTypeEN)} + > + + + + ))} + + + + + + + + + + + + + + ); +} diff --git a/src/user/ui-profile/Chart.tsx b/src/user/ui-profile/Chart.tsx new file mode 100644 index 00000000..6e89af4c --- /dev/null +++ b/src/user/ui-profile/Chart.tsx @@ -0,0 +1,96 @@ +import { EmotionLog, EmotionTypeEN } from '@/types/emotion'; +import Image from 'next/image'; +import { iconPaths } from '../utill/constants'; + +interface ChartProps { + monthlyEmotionLogs: EmotionLog[]; +} + +export default function Chart({ monthlyEmotionLogs }: ChartProps) { + // 감정별 빈도수 계산 + const emotionCounts = monthlyEmotionLogs.reduce( + (count, log) => { + const { emotion } = log; + return { + ...count, // 기존의 count를 복사 + [emotion]: (count[emotion] || 0) + 1, // 현재 감정의 개수 증가 + }; + }, + {} as Record, + ); + + // 감정 종류 및 총 감정 수 계산 + const TOTAL_COUNT = monthlyEmotionLogs.length; + const EMOTIONS: EmotionTypeEN[] = ['MOVED', 'HAPPY', 'WORRIED', 'SAD', 'ANGRY']; + const RADIUS = 90; // 원의 반지름 + const CIRCUMFERENCE = 2 * Math.PI * RADIUS; + + // 가장 많이 나타나는 감정 찾기 + const maxEmotion = EMOTIONS.reduce((max, emotion) => (emotionCounts[emotion] > emotionCounts[max] ? emotion : max), EMOTIONS[0]); + + // 원형 차트의 각 감정에 대한 strokeDasharray와 strokeDashoffset 계산 + let offset = 0; + + return ( + + 감정 차트 + + + + + {EMOTIONS.map((emotion) => { + const count = emotionCounts[emotion] || 0; + const percentage = TOTAL_COUNT > 0 ? count / TOTAL_COUNT : 0; // 0으로 나누기 방지 + const strokeDasharray = `${CIRCUMFERENCE * percentage} ${CIRCUMFERENCE * (1 - percentage)}`; + + // 색상 설정 + let strokeColor; + switch (emotion) { + case 'HAPPY': + strokeColor = '#FBC85B'; + break; + case 'SAD': + strokeColor = '#E3E9F1'; + break; + case 'WORRIED': + strokeColor = '#C7D1E0'; + break; + case 'ANGRY': + strokeColor = '#EFF3F8'; + break; + default: + strokeColor = '#48BB98'; + } + + const circle = ; + + offset += CIRCUMFERENCE * percentage; // 다음 원을 위한 offset 업데이트 + return circle; + })} + + {/* 중앙에 가장 많이 나타나는 감정 출력 */} + + + {iconPaths[maxEmotion].name} + + + + + {EMOTIONS.map((emotion) => { + const count = emotionCounts[emotion] || 0; + const percentage = TOTAL_COUNT > 0 ? Math.floor((count / TOTAL_COUNT) * 100) : 0; // 퍼센트 계산 및 소수점 버리기 + + return ( + + + + {percentage}% + + ); + })} + + + + + ); +} diff --git a/src/user/ui-profile/EmotionMonthlyLogs.tsx b/src/user/ui-profile/EmotionMonthlyLogs.tsx new file mode 100644 index 00000000..18d4ada8 --- /dev/null +++ b/src/user/ui-profile/EmotionMonthlyLogs.tsx @@ -0,0 +1,40 @@ +import { useMonthlyEmotionLogs } from '@/hooks/useGetEmotion'; +import { Emotion } from '@/types/emotion'; +import { useEffect, useState } from 'react'; +import Calendar from './Calendar'; +import Chart from './Chart'; + +interface EmotionMonthlyLogsProps { + userId: number; +} + +export default function EmotionMonthlyLogs({ userId }: EmotionMonthlyLogsProps) { + // 현재 날짜를 상태로 관리 + const [currentDate, setCurrentDate] = useState(new Date()); + + // 감정 달력 객체 상태 추가 + const [emotionRequest, setEmotionRequest] = useState({ + userId, + year: currentDate.getFullYear(), + month: currentDate.getMonth() + 1, + }); + + // '월'이 변경될 때마다 request 업데이트 + useEffect(() => { + setEmotionRequest({ + userId, + year: currentDate.getFullYear(), + month: currentDate.getMonth() + 1, + }); + }, [currentDate]); + + // 월별 감정 로그 조회 + const { data: monthlyEmotionLogs = [] } = useMonthlyEmotionLogs(emotionRequest); + + return ( + <> + + + > + ); +} diff --git a/src/user/ui-profile/Profile.tsx b/src/user/ui-profile/Profile.tsx new file mode 100644 index 00000000..42fd5f74 --- /dev/null +++ b/src/user/ui-profile/Profile.tsx @@ -0,0 +1,41 @@ +import Image from 'next/image'; +import { UserProfileProps } from '@/types/user'; +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogTrigger, DialogContent } from '@/components/ui/dialog'; +import { sampleImage } from '../utill/constants'; +import ProfileEdit from './ProfileEdit'; + +export default function Profile({ image, nickname }: UserProfileProps) { + const [isModalOpen, setIsModalOpen] = useState(false); + + const handleProfileEditClose = () => { + setIsModalOpen(false); + }; + + // TODO: 여러개의 샘플 이미지 랜덤하게 뜨도록 추가 할 예정 + const profileImage = image || sampleImage; + + return ( + + + + + + + {nickname} + + + + + 프로필 수정 + + + + + + + + + ); +} diff --git a/src/user/ui-profile/ProfileEdit.tsx b/src/user/ui-profile/ProfileEdit.tsx new file mode 100644 index 00000000..218f2c7a --- /dev/null +++ b/src/user/ui-profile/ProfileEdit.tsx @@ -0,0 +1,140 @@ +import Image from 'next/image'; +import { UserProfileProps } from '@/types/user'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import Label from '@/components/ui/label'; +import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { useToast } from '@/components/ui/use-toast'; +import { useEffect, useRef } from 'react'; +import { Form, Formik, useFormik } from 'formik'; +import { useCreatePresignedUrl, useUpdateMe } from '@/hooks/userQueryHooks'; +import * as Yup from 'yup'; +import { AxiosError } from 'axios'; +import fileNameChange from '../utill/fileNameChange'; + +interface UserProfileEditProps { + initialValues: { + image: string; + nickname: string; + }; + onModalClose: () => void; +} + +const validationSchema = Yup.object().shape({ + nickname: Yup.string().min(1, '닉네임은 1자 이상 30자 이하여야 합니다.').max(30, '닉네임은 1자 이상 30자 이하여야 합니다.').required('닉네임은 필수 항목입니다.'), +}); + +export default function ProfileEdit({ initialValues, onModalClose }: UserProfileEditProps) { + const createPresignedUrl = useCreatePresignedUrl(); + const fileInputRef = useRef(null); + + const { toast } = useToast(); + + const handleSubmit = async () => { + await formik.submitForm(); // Formik의 submitForm 함수 호출 + }; + + const { mutate: updateMe } = useUpdateMe({ + onSuccess: () => { + onModalClose(); + toast({ + description: '프로필 수정이 완료되었습니다.', + }); + }, + onError: () => { + toast({ + description: '프로필 수정 실패', + }); + }, + }); + + const formik = useFormik({ + initialValues: { + image: '', + nickname: '', + }, + validationSchema, + onSubmit: async (values, { setSubmitting }) => { + try { + // 프로필 업데이트 + await updateProfile(values); + setSubmitting(false); + } catch (error) { + // 에러 처리 + } finally { + setSubmitting(false); + } + }, + }); + + const updateProfile = (values: UserProfileProps) => { + updateMe(values); + }; + + // 프로필 사진 변경 클릭 + const handleImageEditClick = () => { + if (fileInputRef.current) { + fileInputRef.current.click(); + } + }; + + // 이미지 변경 시 + async function handleImageChange(e: React.ChangeEvent): Promise { + const { files } = e.currentTarget; + if (files && files.length > 0) { + const file = files[0]; + + try { + // 중복된 파일명 및 한글파일이 저장되지 않도록 파일이름 포멧 변경 + const newFileName = fileNameChange(); + const newFile = new File([file], `${newFileName}.${file.name.split('.').pop()}`, { type: file.type }); + + // presignedUrl 구하는 함수 (s3 업로드까지 같이) + const { url } = await createPresignedUrl.mutateAsync({ image: newFile }); + formik.setFieldValue('image', url); + } catch (error) { + // 에러 처리: 실패 시 토스트 메시지 + const axiosError = error as AxiosError; + + onModalClose(); + const errorMessage = `(error: ${axiosError.response?.status}) 잘못 된 요청입니다. 관리자에게 문의해주세요`; + + toast({ + description: errorMessage, + className: 'bg-red-400 text-white', + }); + } + } + } + + useEffect(() => { + formik.setValues(initialValues); + }, [initialValues]); + + return ( + + {({ isSubmitting }) => ( + + + 프로필 수정 + + + + handleImageChange(e)} className='hidden' ref={fileInputRef} /> + + + 닉네임 + + + + + + 수정하기 + + + + + )} + + ); +} diff --git a/src/user/utill/constants.ts b/src/user/utill/constants.ts new file mode 100644 index 00000000..9ff423c8 --- /dev/null +++ b/src/user/utill/constants.ts @@ -0,0 +1,20 @@ +// 파일 업로드 관련 +export const MAX_FILE_SIZE = 1024 * 1024 * 5; // 파일 업로드 최대 용량 5MB +export const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png']; // 허용 가능 확장자 +export const sampleImage = '/ProfileTestImage.jpg'; // 초기프로필 이미지 + +// 캘린더 관련 상수 +export const DAY_LIST = ['일', '월', '화', '수', '목', '금', '토']; // 요일 +export const DATE_MONTH_FIXER = 1; // 날짜 조정 상수 (현재 사용되지 않음, 필요에 따라 활용 가능) +export const CALENDAR_LENGTH = 42; // 6주에 맞추어 캘린더의 총 길이를 42로 설정 +export const DAY_OF_WEEK = 7; // 한 주의 날 수 (일~토) +export const DEFAULT_TRASH_VALUE = -1; // 기본값 설정 (필요에 따라 사용 가능) + +// 아이콘 파일 경로 매핑 +export const iconPaths = { + MOVED: { path: '/icon/Color/HeartFaceColorIcon.svg', name: '기쁨', color: 'bg-illust-green' }, + HAPPY: { path: '/icon/Color/SmileFaceColorIcon.svg', name: '감동', color: 'bg-illust-yellow' }, + WORRIED: { path: '/icon/Color/ThinkFaceColorIcon.svg', name: '고민', color: 'bg-sub-gray_1' }, + SAD: { path: '/icon/Color/SadFaceColorIcon.svg', name: '슬픔', color: 'bg-sub-gray_2' }, + ANGRY: { path: '/icon/Color/AngryFaceColorIcon.svg', name: '분노', color: 'bg-sub-gray_3' }, +}; diff --git a/src/user/utill/fileNameChange.ts b/src/user/utill/fileNameChange.ts new file mode 100644 index 00000000..73007d0c --- /dev/null +++ b/src/user/utill/fileNameChange.ts @@ -0,0 +1,8 @@ +function fileNameChange() { + const now = new Date(); + const formattedFileName = `profile${now.getHours()}${now.getMinutes()}${now.getSeconds()}${now.getDate()}${now.getMonth() + 1}${now.getFullYear()}`; + + return formattedFileName; +} + +export default fileNameChange; diff --git a/src/utils/emotionMap.ts b/src/utils/emotionMap.ts new file mode 100644 index 00000000..3c9585f7 --- /dev/null +++ b/src/utils/emotionMap.ts @@ -0,0 +1,23 @@ +import { EmotionType } from '@/types/emotion'; + +const emotionMap: Record = { + 감동: 'MOVED', + 기쁨: 'HAPPY', + 고민: 'WORRIED', + 슬픔: 'SAD', + 분노: 'ANGRY', +}; + +const reverseEmotionMap: Record<'MOVED' | 'HAPPY' | 'WORRIED' | 'SAD' | 'ANGRY', EmotionType> = { + MOVED: '감동', + HAPPY: '기쁨', + WORRIED: '고민', + SAD: '슬픔', + ANGRY: '분노', +}; + +const translateEmotionToEnglish = (emotion: EmotionType): 'MOVED' | 'HAPPY' | 'WORRIED' | 'SAD' | 'ANGRY' => emotionMap[emotion]; + +const translateEmotionToKorean = (emotion: 'MOVED' | 'HAPPY' | 'WORRIED' | 'SAD' | 'ANGRY'): EmotionType => reverseEmotionMap[emotion]; + +export { translateEmotionToEnglish, translateEmotionToKorean }; diff --git a/tsconfig.json b/tsconfig.json index fb68dc1a..3cb96de7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,7 @@ "isolatedModules": true, "jsx": "preserve", "incremental": true, + "baseUrl": ".", "paths": { "@/*": ["./src/*"] }
Loading...
{getError.message}
내 에피그램(19)
내 댓글(110)
{day}
{iconPaths[maxEmotion].name}
{percentage}%
{nickname}