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} +
+
{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 ( + + ); +} + +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) => ( + + ))} +
+
+
+ ); +} + +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 ( + <> +
{}} /> +
+
+ + ( + + + 내용 + * + + +