diff --git a/.github/workflows/dockerhub-push.yml b/.github/workflows/dockerhub-push.yml index a5211e8c..d0117d41 100644 --- a/.github/workflows/dockerhub-push.yml +++ b/.github/workflows/dockerhub-push.yml @@ -12,72 +12,71 @@ jobs: runs-on: ubuntu-latest steps: - - name: Checkout Repository - uses: actions/checkout@v2 + - name: Checkout Repository + uses: actions/checkout@v2 - - name: Set Environment Variables - run: | - BRANCH_NAME=$(echo $GITHUB_REF | awk -F'/' '{print $3}') - echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV - - - name: Cache Gradle dependencies - uses: actions/cache@v2 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - gradle- + - name: Set Environment Variables + run: | + BRANCH_NAME=$(echo $GITHUB_REF | awk -F'/' '{print $3}') + echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV - - name: Set up JDK 17 - uses: actions/setup-java@v2 - with: - java-version: '17' - distribution: 'temurin' + - name: Cache Gradle dependencies + uses: actions/cache@v2 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + gradle- - - name: Grant execute permission for gradlew - run: chmod +x src/${{ env.BRANCH_NAME }}-service/gradlew + - name: Set up JDK 17 + uses: actions/setup-java@v2 + with: + java-version: '17' + distribution: 'temurin' - - name: Build with Gradle - env: - USER_DB_URI: ${{ secrets.USER_DB_URI }} - USER_DB_USERNAME: ${{ secrets.USER_DB_USERNAME }} - USER_DB_PASSWORD: ${{ secrets.USER_DB_PASSWORD }} + - name: Grant execute permission for gradlew + run: chmod +x src/${{ env.BRANCH_NAME }}-service/gradlew - SOCIAL_DB_USERNAME: ${{ secrets.SOCIAL_DB_USERNAME }} - SOCIAL_DB_PASSWORD: ${{ secrets.SOCIAL_DB_PASSWORD }} - SOCIAL_DB_URI: ${{ secrets.SOCIAL_DB_URI }} - - run: | - cd src/${{ env.BRANCH_NAME }}-service - ./gradlew clean build -x test + - name: Build with Gradle + env: + USER_DB_URI: ${{ secrets.USER_DB_URI }} + USER_DB_USERNAME: ${{ secrets.USER_DB_USERNAME }} + USER_DB_PASSWORD: ${{ secrets.USER_DB_PASSWORD }} - - name: Build and Push Docker Image - env: - DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} - DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} - BRANCH_NAME: ${{ env.BRANCH_NAME }} + SOCIAL_DB_USERNAME: ${{ secrets.SOCIAL_DB_USERNAME }} + SOCIAL_DB_PASSWORD: ${{ secrets.SOCIAL_DB_PASSWORD }} + SOCIAL_DB_URI: ${{ secrets.SOCIAL_DB_URI }} - USER_DB_URI: ${{ secrets.USER_DB_URI }} - USER_DB_USERNAME: ${{ secrets.USER_DB_USERNAME }} - USER_DB_PASSWORD: ${{ secrets.USER_DB_PASSWORD }} - - SOCIAL_DB_USERNAME: ${{ secrets.SOCIAL_DB_USERNAME }} - SOCIAL_DB_PASSWORD: ${{ secrets.SOCIAL_DB_PASSWORD }} - SOCIAL_DB_URI: ${{ secrets.SOCIAL_DB_URI }} - - run: | - if [ -n "$BRANCH_NAME" ]; then - DOCKERFILE_DIR="src/$BRANCH_NAME-service" - else - echo "Failed to extract branch name from GITHUB_REF." - exit 1 - fi + run: | + cd src/${{ env.BRANCH_NAME }}-service + ./gradlew clean build -x test - cd $DOCKERFILE_DIR - docker run --privileged --rm tonistiigi/binfmt --install all - docker buildx create --use - docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD - docker buildx build --platform=linux/amd64,linux/arm64 -t $DOCKER_USERNAME/$BRANCH_NAME-service:latest . --push - \ No newline at end of file + - name: Build and Push Docker Image + env: + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + BRANCH_NAME: ${{ env.BRANCH_NAME }} + + USER_DB_URI: ${{ secrets.USER_DB_URI }} + USER_DB_USERNAME: ${{ secrets.USER_DB_USERNAME }} + USER_DB_PASSWORD: ${{ secrets.USER_DB_PASSWORD }} + + SOCIAL_DB_USERNAME: ${{ secrets.SOCIAL_DB_USERNAME }} + SOCIAL_DB_PASSWORD: ${{ secrets.SOCIAL_DB_PASSWORD }} + SOCIAL_DB_URI: ${{ secrets.SOCIAL_DB_URI }} + + run: | + if [ -n "$BRANCH_NAME" ]; then + DOCKERFILE_DIR="src/$BRANCH_NAME-service" + else + echo "Failed to extract branch name from GITHUB_REF." + exit 1 + fi + + cd $DOCKERFILE_DIR + docker run --privileged --rm tonistiigi/binfmt --install all + docker buildx create --use + docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD + docker buildx build --platform=linux/amd64,linux/arm64 -t $DOCKER_USERNAME/$BRANCH_NAME-service:latest . --push diff --git a/src/web/.eslintrc.cjs b/src/web/.eslintrc.cjs index 6a3dade6..f1a78a70 100644 --- a/src/web/.eslintrc.cjs +++ b/src/web/.eslintrc.cjs @@ -35,5 +35,19 @@ module.exports = { "allowedInvalidRoles": ["text"], "ignoreNonDOM": true }], // role="text"만 예외로 허용하여 ARIA spec을 확장해서 사용합니다. + "max-classes-per-file": [ + "error", + { "ignoreExpressions": true, "max": 2 } + ], // class는 최대 2개까지 사용 가능합니다. + "class-methods-use-this": "off", // this 사용에 대한 제한을 사용하지 않습니다. + "no-param-reassign": "off", // param을 reassign 할 수 있게 만듭니다. + "react/no-unstable-nested-components": [ + "off", + { + "allowAsProps": true, + "customValidators": [] /* optional array of validators used for propTypes validation */ + } + ],// error-boundary에서 fallback을 받기위해 사용합니다. + "no-restricted-syntax": "off" // for ... of의 문법을 사용합니다. }, }; diff --git a/src/web/.gitignore b/src/web/.gitignore index 97bee46e..f1fd06bf 100644 --- a/src/web/.gitignore +++ b/src/web/.gitignore @@ -24,3 +24,4 @@ dist-ssr *.sw? coverage +.env diff --git a/src/web/index.html b/src/web/index.html index 7d3b3572..1fcfeb47 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -3,6 +3,11 @@ + + + + + =10'} + peerDependencies: + react: ^16.3.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@cloudinary/html': 1.11.2 + react: 18.2.0 + dev: false + + /@cloudinary/transformation-builder-sdk@1.10.1: + resolution: {integrity: sha512-UUb1wS/eWCf4YBThGszoBBzH6kP+frdd5JeJkF0/SOwbX3tkcrdzxD+Srn5GXPCqzf6Gw1nrGrv/3U9hiZP55A==} + dependencies: + '@cloudinary/url-gen': 1.16.0 + dev: false + + /@cloudinary/url-gen@1.16.0: + resolution: {integrity: sha512-0hbxLuoTGgd555LLZKf8Ut5ey3lIaRTyvtvqD99rLeKUsbq8vrdFoSSwIP+se8pglO5ZSz6y8x6Iu5ddY3wyMw==} + dependencies: + '@cloudinary/transformation-builder-sdk': 1.10.1 + dev: false + /@cspotcode/source-map-support@0.8.1: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -1565,6 +1616,18 @@ packages: resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} dev: true + /@types/lodash.clonedeep@4.5.9: + resolution: {integrity: sha512-19429mWC+FyaAhOLzsS8kZUsI+/GmBAQ0HFiCPsKGU+7pBXOQWhyrY6xNNDwUSX8SMZMJvuFVMF9O5dQOlQK9Q==} + dependencies: + '@types/lodash': 4.14.202 + dev: false + + /@types/lodash.debounce@4.0.9: + resolution: {integrity: sha512-Ma5JcgTREwpLRwMM+XwBR7DaWe96nC38uCBDFKZWbNKD+osjVzdpnUSwBcqCptrp16sSOLBAUb50Car5I0TCsQ==} + dependencies: + '@types/lodash': 4.14.202 + dev: false + /@types/lodash.throttle@4.1.9: resolution: {integrity: sha512-PCPVfpfueguWZQB7pJQK890F2scYKoDUL3iM522AptHWn7d5NQmeS/LTEHIcLr5PaTzl3dK2Z0xSUHHTHwaL5g==} dependencies: @@ -1573,7 +1636,10 @@ packages: /@types/lodash@4.14.202: resolution: {integrity: sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==} - dev: true + + /@types/node@14.18.63: + resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==} + dev: false /@types/node@20.10.8: resolution: {integrity: sha512-f8nQs3cLxbAFc00vEU59yf9UyGUftkPaLGfvbVOIDdx2i1b8epBqj2aNGyP19fiyXWvlmZ7qC1XLjAzw/OKIeA==} @@ -1591,6 +1657,12 @@ packages: '@types/react': 18.2.43 dev: true + /@types/react-helmet@6.1.11: + resolution: {integrity: sha512-0QcdGLddTERotCXo3VFlUSWO3ztraw8nZ6e3zJSgG7apwV5xt+pJUS8ewPBqT4NYB1optGLprNQzFleIY84u/g==} + dependencies: + '@types/react': 18.2.43 + dev: true + /@types/react-lottie@1.2.10: resolution: {integrity: sha512-rCd1p3US4ELKJlqwVnP0h5b24zt5p9OCvKUoNpYExLqwbFZMWEiJ6EGLMmH7nmq5V7KomBIbWO2X/XRFsL0vCA==} dependencies: @@ -2065,7 +2137,6 @@ packages: /asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - dev: true /autoprefixer@10.4.16(postcss@8.4.33): resolution: {integrity: sha512-7vd3UC6xKp0HLfua5IjZlcXvGAGy7cBAXTg2lyQ/8WpNhd6SiZ8Be+xm3FyBSYJx5GKcpRCzBh7RH4/0dnY+uQ==} @@ -2093,6 +2164,16 @@ packages: engines: {node: '>=4'} dev: true + /axios@1.6.6: + resolution: {integrity: sha512-XZLZDFfXKM9U/Y/B4nNynfCRUqNyVZ4sBC/n9GDRCkq9vd2mIvKjKKsbIh1WPmHmNbg6ND7cTBY3Y2+u1G3/2Q==} + dependencies: + follow-redirects: 1.15.5 + form-data: 4.0.0 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + dev: false + /axobject-query@3.2.1: resolution: {integrity: sha512-jsyHu61e6N4Vbz/v18DHwWYKK0bSWLqn47eeDSKPB7m8tqMHF9YJ+mhIk2lVteyZrY8tnSj/jHOv4YiTCuCJgg==} dependencies: @@ -2373,7 +2454,6 @@ packages: engines: {node: '>= 0.8'} dependencies: delayed-stream: 1.0.0 - dev: true /commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} @@ -2574,7 +2654,6 @@ packages: /delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} - dev: true /dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} @@ -3291,6 +3370,16 @@ packages: resolution: {integrity: sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==} dev: true + /follow-redirects@1.15.5: + resolution: {integrity: sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + dev: false + /for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} dependencies: @@ -3312,7 +3401,6 @@ packages: asynckit: 0.4.0 combined-stream: 1.0.8 mime-types: 2.1.35 - dev: true /fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} @@ -3620,6 +3708,12 @@ packages: side-channel: 1.0.4 dev: true + /invariant@2.2.4: + resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + dependencies: + loose-envify: 1.4.0 + dev: false + /is-arguments@1.1.1: resolution: {integrity: sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==} engines: {node: '>= 0.4'} @@ -4504,6 +4598,14 @@ packages: p-locate: 5.0.0 dev: true + /lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + dev: false + + /lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + dev: false + /lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} dev: true @@ -4592,14 +4694,12 @@ packages: /mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} - dev: true /mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} dependencies: mime-db: 1.52.0 - dev: true /mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} @@ -5032,6 +5132,10 @@ packages: react-is: 16.13.1 dev: true + /proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + dev: false + /psl@1.9.0: resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==} dev: true @@ -5071,6 +5175,23 @@ packages: react: 18.2.0 dev: false + /react-fast-compare@3.2.2: + resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} + dev: false + + /react-helmet-async@2.0.4(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-yxjQMWposw+akRfvpl5+8xejl4JtUlHnEBcji6u8/e6oc7ozT+P9PNTWMhCbz2y9tc5zPegw2BvKjQA+NwdEjQ==} + peerDependencies: + react: ^16.6.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.6.0 || ^17.0.0 || ^18.0.0 + dependencies: + invariant: 2.2.4 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-fast-compare: 3.2.2 + shallowequal: 1.1.0 + dev: false + /react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} dev: true @@ -5311,6 +5432,10 @@ packages: has-property-descriptors: 1.0.1 dev: true + /shallowequal@1.1.0: + resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==} + dev: false + /shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -5814,6 +5939,12 @@ packages: is-typed-array: 1.1.12 dev: true + /typescript@4.9.5: + resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==} + engines: {node: '>=4.2.0'} + hasBin: true + dev: false + /typescript@5.2.2: resolution: {integrity: sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==} engines: {node: '>=14.17'} @@ -5887,6 +6018,14 @@ packages: convert-source-map: 2.0.0 dev: true + /vite-plugin-environment@1.1.3(vite@5.0.8): + resolution: {integrity: sha512-9LBhB0lx+2lXVBEWxFZC+WO7PKEyE/ykJ7EPWCq95NEcCpblxamTbs5Dm3DLBGzwODpJMEnzQywJU8fw6XGGGA==} + peerDependencies: + vite: '>= 2.7' + dependencies: + vite: 5.0.8(@types/node@20.10.8) + dev: true + /vite-plugin-svgr@4.2.0(typescript@5.2.2)(vite@5.0.8): resolution: {integrity: sha512-SC7+FfVtNQk7So0XMjrrtLAbEC8qjFPifyD7+fs/E6aaNdVde6umlVVh0QuwDLdOMu7vp5RiGFsB70nj5yo0XA==} peerDependencies: diff --git a/src/web/public/assets/favicons/manifest.json b/src/web/public/assets/favicons/manifest.json index 3b557aab..23ad79be 100644 --- a/src/web/public/assets/favicons/manifest.json +++ b/src/web/public/assets/favicons/manifest.json @@ -2,37 +2,37 @@ "name": "App", "icons": [ { - "src": "/android-icon-36x36.png", + "src": "/assets/favicons/android-icon-36x36.png", "sizes": "36x36", "type": "image/png", "density": "0.75" }, { - "src": "/android-icon-48x48.png", + "src": "/assets/favicons/android-icon-48x48.png", "sizes": "48x48", "type": "image/png", "density": "1.0" }, { - "src": "/android-icon-72x72.png", + "src": "/assets/favicons/android-icon-72x72.png", "sizes": "72x72", "type": "image/png", "density": "1.5" }, { - "src": "/android-icon-96x96.png", + "src": "/assets/favicons/android-icon-96x96.png", "sizes": "96x96", "type": "image/png", "density": "2.0" }, { - "src": "/android-icon-144x144.png", + "src": "/assets/favicons/android-icon-144x144.png", "sizes": "144x144", "type": "image/png", "density": "3.0" }, { - "src": "/android-icon-192x192.png", + "src": "/assets/favicons/android-icon-192x192.png", "sizes": "192x192", "type": "image/png", "density": "4.0" diff --git a/src/web/public/robots.txt b/src/web/public/robots.txt new file mode 100644 index 00000000..6f27bb66 --- /dev/null +++ b/src/web/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: \ No newline at end of file diff --git a/src/web/src/@types/api/auth.ts b/src/web/src/@types/api/auth.ts new file mode 100644 index 00000000..a1198da2 --- /dev/null +++ b/src/web/src/@types/api/auth.ts @@ -0,0 +1,42 @@ +export interface Token { + accessToken: string; + refreshToken: string; +} + +export interface User { + id: string; + email: string; + password: string; + profileImagePath: string; + backgroundImagePath: string; + status: 'private' | 'public'; + username: string; + nickname: string; + introduce: string; + websitePath: string; + joinedAt: string; + followerCount: number; + followingCount: number; +} + +export type UserSearchResult = Pick & { + imagePath: User['profileImagePath']; + status?: User['status']; + isFollowing?: boolean; +}; +export type UserProfile = Pick< + User, + | 'id' + | 'backgroundImagePath' + | 'profileImagePath' + | 'nickname' + | 'username' + | 'introduce' + | 'websitePath' + | 'joinedAt' + | 'followerCount' + | 'followingCount' +>; +export type LoginInfo = Pick; +export type JoinInfo = Pick & + Partial>; diff --git a/src/web/src/@types/api/image.ts b/src/web/src/@types/api/image.ts new file mode 100644 index 00000000..e7a797e8 --- /dev/null +++ b/src/web/src/@types/api/image.ts @@ -0,0 +1,4 @@ +export interface ImageSize { + width: number; + height: number; +} diff --git a/src/web/src/@types/api/index.ts b/src/web/src/@types/api/index.ts new file mode 100644 index 00000000..c79588be --- /dev/null +++ b/src/web/src/@types/api/index.ts @@ -0,0 +1,3 @@ +export * from './auth'; +export * from './image'; +export * from './paint'; diff --git a/src/web/src/@types/api/paint.ts b/src/web/src/@types/api/paint.ts new file mode 100644 index 00000000..2aa2d1c4 --- /dev/null +++ b/src/web/src/@types/api/paint.ts @@ -0,0 +1,64 @@ +import type { User } from './auth'; + +export interface TimelineItem { + id: string; + isReply: boolean; + authorId: User['id']; + authorUsername: User['username']; + authorNickname: User['nickname']; + authorImagePath: User['profileImagePath']; + authorStatus: User['status']; + createdAt: string; + text: string; + replyCount: number; + repaintCount: number; + likeCount: number; + like: boolean; + repainted: boolean; + marked: boolean; + views: number; + quotePaint: TimelineItem | null; + entities: { + hashtags: { start: number; end: number; tag: string }[]; + mentions: { start: number; end: number; mention: string; userId: string }[]; + }; + includes: { + medias: { type: 'image' | 'video'; path: string }[]; + users: (Pick & { + imagePath: User['profileImagePath']; + })[]; + links: { + start: number; + end: number; + shortLink: string; + originalLink: string; + }[]; + }; +} + +export interface EditPaint { + text: string; + medias: { + path: string; + type: 'image' | 'video'; + }[]; + taggedUserIds: string[]; + quotePaintId: string; + inReplyToPaintId: string; + hashtags: { + start: number; + end: number; + tag: string; + }[]; + mentions: { + start: number; + end: number; + userId: User['id']; + mention: string; + }[]; + links: { + start: number; + end: number; + link: string; + }[]; +} diff --git a/src/web/src/@types/direction.ts b/src/web/src/@types/direction.ts new file mode 100644 index 00000000..d1873200 --- /dev/null +++ b/src/web/src/@types/direction.ts @@ -0,0 +1,4 @@ +export type Direction = 'stop' | 'up' | 'down'; +export interface ScrollDirectionProps { + direction: Direction; +} diff --git a/src/web/src/@types/index.ts b/src/web/src/@types/index.ts index 98085b7f..e1247d0d 100644 --- a/src/web/src/@types/index.ts +++ b/src/web/src/@types/index.ts @@ -1,2 +1,3 @@ -export * from './models'; +export * from './api'; +export * from './direction'; export * from './styles'; diff --git a/src/web/src/@types/models.ts b/src/web/src/@types/models.ts deleted file mode 100644 index 60faae4b..00000000 --- a/src/web/src/@types/models.ts +++ /dev/null @@ -1,42 +0,0 @@ -export interface User { - id: string; - email: string; - profileImagePath: string; - backgroundImagePath: string; - status: 'private' | 'public'; - username: string; - nickname: string; - introduce: string; - websitePath: string; - createdAt: Date; - followers: number; - followings: number; -} - -export interface TimelineItem { - id: string; - isReply: boolean; - authorId: User['id']; - authorUsername: User['username']; - authorNickname: User['nickname']; - authorImagePath: User['profileImagePath']; - authorStatus: User['status']; - createdAt: Date; - text: string; - replyCount: number; - repaintCount: number; - likeCount: number; - views: number; - like: boolean; - repainted: boolean; - marked: boolean; - entities: { - hashtags: { start: number; end: number; tag: string }[]; - mentions: { start: number; end: number; mention: string; userId: string }[]; - }; - includes: { - medias: { type: 'image' | 'video'; path: string }[]; - paint: TimelineItem | null; - users: Pick[]; - }; -} diff --git a/src/web/src/@types/styles.ts b/src/web/src/@types/styles.ts index c8ac582a..d203b667 100644 --- a/src/web/src/@types/styles.ts +++ b/src/web/src/@types/styles.ts @@ -35,8 +35,9 @@ export type ColorType = | 'green-100' | 'green-200' | 'yellow-100' + | 'pink-100' + | 'pink-200' | 'red-100' - | 'red-200' | 'purple-100' | 'purple-200' | 'blue-100' diff --git a/src/web/src/api/AuthTokenStorage.ts b/src/web/src/api/AuthTokenStorage.ts new file mode 100644 index 00000000..f4c7366c --- /dev/null +++ b/src/web/src/api/AuthTokenStorage.ts @@ -0,0 +1,38 @@ +import { generateLocalStorage } from '@/utils'; +import { AuthenticationRequiredError } from './AuthenticationRequiredError'; + +export class AuthTokenStorage { + private authToken: string | null; + + private storage: ReturnType>; + + constructor(key: string) { + this.storage = generateLocalStorage(key); + this.authToken = this.storage.get(); + } + + getToken(): AuthTokenStorage['authToken'] { + return this.authToken; + } + + getTokenOrThrow(): AuthTokenStorage['authToken'] { + if (this.authToken == null) { + throw new AuthenticationRequiredError(); + } + return this.authToken; + } + + clear(): void { + this.authToken = null; + this.storage.remove(); + } + + setToken(token: string): void { + this.authToken = token; + this.storage.set(token); + } +} + +export const userIdStorage = generateLocalStorage('@@@userId'); +export const accessTokenStorage = new AuthTokenStorage('@@@accessToken'); +export const refreshTokenStorage = new AuthTokenStorage('@@@refreshToken'); diff --git a/src/web/src/api/AuthenticationRequiredError.ts b/src/web/src/api/AuthenticationRequiredError.ts new file mode 100644 index 00000000..2fa065a4 --- /dev/null +++ b/src/web/src/api/AuthenticationRequiredError.ts @@ -0,0 +1,5 @@ +export class AuthenticationRequiredError extends Error { + constructor() { + super(`로그인이 필요한 서비스입니다`); + } +} diff --git a/src/web/src/api/apiFactory.ts b/src/web/src/api/apiFactory.ts new file mode 100644 index 00000000..3ee07761 --- /dev/null +++ b/src/web/src/api/apiFactory.ts @@ -0,0 +1,28 @@ +import axios from 'axios'; + +import { env } from '@/constants'; +import { accessTokenStorage } from './AuthTokenStorage'; + +export const createApiClient = ({ auth }: { auth: boolean }) => { + const client = axios.create({ + baseURL: `${env.VITE_BASE_SERVER_URL}`, + }); + + if (auth) { + client.interceptors.request.use((config) => { + const token = accessTokenStorage.getTokenOrThrow(); + config.headers.Authorization = `Bearer ${token}`; + return config; + }); + } + + return client; +}; + +export const cdnAPIClient = () => { + const client = axios.create({ + baseURL: `${env.VITE_CDN_BASE_URL}/${env.VITE_CLOUD_NAME}/`, + }); + + return client; +}; diff --git a/src/web/src/api/handler.ts b/src/web/src/api/handler.ts new file mode 100644 index 00000000..72ce567a --- /dev/null +++ b/src/web/src/api/handler.ts @@ -0,0 +1,26 @@ +import type { AxiosResponse } from 'axios'; + +export const handleResponse = async ( + promise: Promise>, +): Promise => { + const response = await promise; + return response.data as T; +}; + +export const createApiWrappers = >( + apis: T, +): { + [K in keyof T]: T[K] extends ( + ...args: infer A + ) => Promise> + ? (...args: A) => Promise + : never; +} => { + const mapped: any = {}; + + Object.keys(apis).forEach((key) => { + const method = apis[key]; + mapped[key] = (...args: any[]) => handleResponse(method(...args)); + }); + return mapped; +}; diff --git a/src/web/src/api/index.ts b/src/web/src/api/index.ts new file mode 100644 index 00000000..e65d1290 --- /dev/null +++ b/src/web/src/api/index.ts @@ -0,0 +1,216 @@ +import { env } from '@/constants'; +import { createApiWrappers } from './handler'; +import type { + EditPaint, + JoinInfo, + LoginInfo, + TimelineItem, + User, + UserProfile, + UserSearchResult, +} from '@/@types'; +import { cdnAPIClient, createApiClient } from './apiFactory'; + +const client = { + public: createApiClient({ auth: false }), + private: createApiClient({ auth: true }), + cdn: cdnAPIClient(), +} as const; + +const auth = createApiWrappers({ + verifyEmailCode: (request: { email: User['email']; payload: string }) => + client.public.post('/auth', request), + reSendEmailCode: (request: { email: User['email'] }) => + client.public.post('/auth/resend', request), + login: (request: LoginInfo) => + client.public.post<{ accessToken: string; refreshToken: string }>( + '/auth/mobile', + request, + ), + logout: () => client.private.post('/auth/web-logout'), +}); + +const users = createApiWrappers({ + checkDuplicateEmail: ({ email }: { email: User['email'] }) => + client.public.post<{ isDuplicated: boolean }>('/users/verify-email', { + email, + }), + checkDuplicateUsername: ({ username }: { username: User['username'] }) => + client.public.post<{ isDuplicated: boolean }>('/users/verify-username', { + username, + }), + join: ( + request: Pick, + ) => + client.public.post('/users/join', { + ...request, + introduce: null, + backgroundPath: null, + websitePath: null, + }), + temporaryJoin: (request: Pick) => + client.public.post('/users/temporary-join', request), + getUserProfile: (userId: User['id']) => + client.private.get(`/users/${userId}`), + searchUserByDisplayName: (keyword: string) => + client.private.get(`/users/search?keyword=${keyword}`), + getMyProfile: () => client.private.get(`/users/me`), + updateProfile: ( + request: Pick< + User, + | 'nickname' + | 'introduce' + | 'profileImagePath' + | 'backgroundImagePath' + | 'websitePath' + >, + ) => + client.public.put< + Pick< + User, + | 'email' + | 'profileImagePath' + | 'backgroundImagePath' + | 'username' + | 'nickname' + | 'introduce' + | 'websitePath' + > & { userId: User['id'] } + >('/users/profile', request), + getUserPaints: (userId: User['id']) => + client.private.get(`/users/${userId}/paint`), + getUserReplyPaints: (userId: User['id']) => + client.private.get(`/users/${userId}/reply`), + getUserMediaPaints: (userId: User['id']) => + client.private.get(`/users/${userId}/media`), + getUserLikePaints: (userId: User['id']) => + client.private.get(`/users/${userId}/heart`), + followUser: (userId: User['id']) => + client.private.post(`/users/${userId}/follow`), + unFollowUser: (userId: User['id']) => + client.private.delete(`/users/${userId}/follow`), + likePaint: ({ + userId, + paintId, + }: { + userId: User['id']; + paintId: TimelineItem['id']; + }) => + client.private.post<{ paintId: TimelineItem['id'] }>( + `/users/${userId}/like`, + { paintId }, + ), + disLikePaint: ({ + userId, + paintId, + }: { + userId: User['id']; + paintId: TimelineItem['id']; + }) => + client.private.delete<{ paintId: TimelineItem['id'] }>( + `/users/${userId}/like/${paintId}`, + ), + rePaint: ({ + userId, + paintId, + }: { + userId: User['id']; + paintId: TimelineItem['id']; + }) => + client.private.post<{ paintId: TimelineItem['id'] }>( + `/users/${userId}/repaint`, + { paintId }, + ), + markPaint: ({ + userId, + paintId, + }: { + userId: User['id']; + paintId: TimelineItem['id']; + }) => + client.private.post<{ paintId: TimelineItem['id'] }>( + `/users/${userId}/mark`, + { paintId }, + ), + unMarkPaint: ({ + userId, + paintId, + }: { + userId: User['id']; + paintId: TimelineItem['id']; + }) => + client.private.delete<{ paintId: TimelineItem['id'] }>( + `/users/${userId}/mark/${paintId}`, + ), +}); + +const images = createApiWrappers({ + uploadImage: ( + image: File, + timestamp: number, + options: { folder?: string } = {}, + ) => { + const formData = new FormData(); + formData.append('api_key', env.VITE_CLD_API_KEY); + formData.append('upload_preset', env.VITE_CLD_PRESET_NAME); + formData.append('file', image); + formData.append('timestamp', String(Math.round(timestamp / 1000))); + if (options.folder) { + formData.append('folder', options.folder); + } + + return client.cdn.post<{ + access_mode: 'public'; + asset_id: string; + bytes: number; + created_at: string; + etag: string; + folder: string; + format: 'jpg' | 'jpeg' | 'png'; + height: number; + original_filename: string; + placeholder: false; + public_id: string; + resource_type: 'image' | 'video'; + secure_url: string; + signature: string; + tags: []; + type: 'upload'; + url: string; + version: number; + version_id: string; + width: number; + }>('image/upload', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + }, +}); + +const paints = createApiWrappers({ + getPaintById: (paintId: TimelineItem['id']) => + client.private.get(`/paints/${paintId}`), + getBeforePaintsById: (paintId: TimelineItem['id']) => + client.private.get(`/paints/${paintId}/before`), + getAfterPaintsById: (paintId: TimelineItem['id']) => + client.private.get(`/paints/${paintId}/after`), + getPaints: (paintId: TimelineItem['id']) => + client.private.get(`/paints/${paintId}`), + createPaint: (request: EditPaint) => client.private.post('/paints', request), + getQuotePaintList: (paintId: TimelineItem['id']) => + client.private.get< + (Pick & { + includes: { + paints: Pick; + }; + })[] + >(`/paints/${paintId}/quote-paints`), +}); + +export const apis = { + auth, + users, + images, + paints, +} as const; diff --git a/src/web/src/components/AccessibleIconButton.tsx b/src/web/src/components/AccessibleIconButton.tsx index fc35da1b..445f28f8 100644 --- a/src/web/src/components/AccessibleIconButton.tsx +++ b/src/web/src/components/AccessibleIconButton.tsx @@ -1,4 +1,5 @@ -import { forwardRef } from 'react'; +import { motion } from 'framer-motion'; +import { forwardRef, memo } from 'react'; import type { ButtonHTMLAttributes, Ref } from 'react'; import type { IconKeyType } from './common/Icon'; @@ -42,9 +43,13 @@ const AccessibleIconButton = forwardRef( stroke={stroke} width={width} height={height} + aria-hidden /> ), ); -export default AccessibleIconButton; +const MemoizedAccessibleIconButton = memo(AccessibleIconButton); +export const FramerAccessibleIconButton = memo(motion(AccessibleIconButton)); + +export default MemoizedAccessibleIconButton; diff --git a/src/web/src/components/AfterTimelineList.tsx b/src/web/src/components/AfterTimelineList.tsx new file mode 100644 index 00000000..ad8d56e3 --- /dev/null +++ b/src/web/src/components/AfterTimelineList.tsx @@ -0,0 +1,67 @@ +import { forwardRef, memo } from 'react'; +import type { ForwardedRef } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import { useSuspenseQuery } from '@tanstack/react-query'; + +import { apis } from '@/api'; +import { cn } from '@/utils'; +import type { PaintAction } from '@/hooks'; +import { postDetailRoute } from '@/routes'; +import TimelineItemBox from './TimelineItemBox'; + +interface AfterTimelineListProps { + className?: string; + paintAction: PaintAction; +} + +const AfterTimelineList = forwardRef( + ({ className, paintAction }, ref: ForwardedRef) => { + const navigate = useNavigate(); + const params = postDetailRoute.useParams(); + const { data: posts } = useSuspenseQuery({ + queryKey: ['post', params.postId, 'before'], + queryFn: () => apis.paints.getBeforePaintsById(params.postId), + }); + + if (Array.isArray(posts) && posts.length === 0) { + return null; + } + + return ( +
+ {posts.map((post) => ( + + navigate({ + to: '/post/edit', + search: { postId: post.id }, + }) + } + onClickRetweet={() => paintAction.onClickRetweet(post.id)} + onClickHeart={() => paintAction.onClickHeart(post.id, post.like)} + onClickViews={() => paintAction.onClickViews(post.id)} + onClickShare={() => paintAction.onClickShare(post.id)} + onClickMore={() => paintAction.onClickMore(post.id)} + /> + ))} +
+ ); + }, +); + +const MemoizedAfterTimelineList = memo(AfterTimelineList); + +export default MemoizedAfterTimelineList; diff --git a/src/web/src/components/BeforeTimelineList.tsx b/src/web/src/components/BeforeTimelineList.tsx new file mode 100644 index 00000000..6992ecf4 --- /dev/null +++ b/src/web/src/components/BeforeTimelineList.tsx @@ -0,0 +1,81 @@ +import { forwardRef, memo, useEffect } from 'react'; +import type { ForwardedRef, RefObject } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import { useSuspenseQuery } from '@tanstack/react-query'; + +import { cn } from '@/utils'; +import { apis } from '@/api'; +import type { PaintAction } from '@/hooks'; +import { postDetailRoute } from '@/routes'; +import TimelineItemBox from './TimelineItemBox'; + +interface BeforeTimelineListProps { + className?: string; + mainPostRef: RefObject; + parentRef: RefObject; + + paintAction: PaintAction; +} + +const BeforeTimelineList = forwardRef( + ( + { className, paintAction, mainPostRef, parentRef }, + ref: ForwardedRef, + ) => { + const navigate = useNavigate(); + const params = postDetailRoute.useParams(); + const { data: posts, isSuccess } = useSuspenseQuery({ + queryKey: ['post', params.postId, 'before'], + queryFn: () => apis.paints.getBeforePaintsById(params.postId), + }); + + if (Array.isArray(posts) && posts.length === 0) { + return
; + } + useEffect(() => { + const $mainPost = mainPostRef.current; + const $parent = parentRef.current; + + if ($mainPost && $parent) { + const headerOffset = 68; + $mainPost.scrollIntoView(); + $parent.scrollBy(0, -headerOffset); + } + }, [mainPostRef.current?.id, isSuccess]); + + return ( +
+ {posts.map((post) => ( + + navigate({ + to: '/post/edit', + search: { postId: post.id }, + }) + } + onClickRetweet={() => paintAction.onClickRetweet(post.id)} + onClickHeart={() => paintAction.onClickHeart(post.id, post.like)} + onClickViews={() => paintAction.onClickViews(post.id)} + onClickShare={() => paintAction.onClickShare(post.id)} + onClickMore={() => paintAction.onClickMore(post.id)} + /> + ))} +
+ ); + }, +); + +const MemoizedBeforeTimelineList = memo(BeforeTimelineList); + +export default MemoizedBeforeTimelineList; diff --git a/src/web/src/components/BottomNavigation.tsx b/src/web/src/components/BottomNavigation.tsx index 4332ba98..385337f4 100644 --- a/src/web/src/components/BottomNavigation.tsx +++ b/src/web/src/components/BottomNavigation.tsx @@ -1,16 +1,15 @@ -import type { RefObject } from 'react'; -import { motion } from 'framer-motion'; -import { useEffect, useState } from 'react'; +import { memo } from 'react'; import { cva } from 'class-variance-authority'; -import { useNavigate, useMatchRoute } from '@tanstack/react-router'; +import { useNavigate } from '@tanstack/react-router'; -import { useThrottle } from '@/hooks'; -import AccessibleIconButton from './AccessibleIconButton'; +import { iconOpacity } from '@/utils'; +import type { ScrollDirectionProps } from '@/@types'; +import { FramerAccessibleIconButton } from './AccessibleIconButton'; const BottomNavigationVariants = cva<{ direction: Record<'up' | 'down' | 'stop', string>; }>( - 'w-[420px] max-w-full h-[50px] leading-[50px] px-[28px] fixed bottom-0 flex justify-between border-t-[1px] bg-white transition-opacity', + 'w-[420px] max-w-full h-[50px] leading-[50px] px-[28px] fixed bottom-0 flex justify-between border-t-[1px] bg-white transition-opacity z-[9995]', { variants: { direction: { @@ -25,104 +24,58 @@ const BottomNavigationVariants = cva<{ }, ); -interface BottomNavigationProps { - contentRef: RefObject | null; -} - -const FramerAccessibleIconButton = motion(AccessibleIconButton); - -const iconOpacity = (direction: 'up' | 'down' | 'stop') => { - if (direction === 'up') return 'opacity-95'; - if (direction === 'down') return 'opacity-80'; - return ''; -}; - -function BottomNavigation({ contentRef }: BottomNavigationProps) { - if (!contentRef) return null; +function BottomNavigation({ direction }: ScrollDirectionProps) { const navigate = useNavigate(); - const matchRoute = useMatchRoute(); - const [y, setY] = useState(0); - const [scrollDirection, setScrollDirection] = useState< - 'stop' | 'up' | 'down' - >('stop'); - - /** - * 아래로 드래그가 되고 있다면 BottomNavigation의 투명도를 주어 컨텐츠를 방해하지 않습니다. - * 위로 드래그가 되고 있다면 투명도를 제거합니다. - * - * 최적화를 위해 throttle을 사용합니다. - */ - const onScroll = useThrottle((e: Event) => { - const padding = 50; - const { scrollTop } = e.target as HTMLElement; - - if (y > scrollTop + padding) { - setScrollDirection('up'); - } else if (y < scrollTop - padding) { - setScrollDirection('down'); - } - setY(scrollTop); - }, 200); - - useEffect(() => { - contentRef.current?.addEventListener('scroll', onScroll); - return () => { - contentRef.current?.removeEventListener('scroll', onScroll); - }; - }, [contentRef.current]); + const pathname = window?.location.pathname ?? '/home'; return ( -