diff --git a/client/.eslintrc.json b/client/.eslintrc.json index 4d69687..146fd9b 100644 --- a/client/.eslintrc.json +++ b/client/.eslintrc.json @@ -53,6 +53,7 @@ "ts": "never", "tsx": "never" } - ] + ], + "no-alert": "off" } } \ No newline at end of file diff --git a/client/.storybook/main.js b/client/.storybook/main.js index 3ae8f8e..afe9103 100644 --- a/client/.storybook/main.js +++ b/client/.storybook/main.js @@ -19,8 +19,9 @@ module.exports = { '@theme': path.resolve(__dirname, '../src/common/theme'), '@utils': path.resolve(__dirname, '../src/common/utils'), '@store': path.resolve(__dirname, '../src/common/store'), - '@dispatch': path.resolve(__dirname, '../src/common/dispatch'), - '@imgs': path.resolve(__dirname, '../public/imgs') + '@imgs': path.resolve(__dirname, '../public/imgs'), + '@socket': path.resolve(__dirname, '../src/common/socket'), + '@constants': path.resolve(__dirname, '../src/common/constants') } return config; } diff --git a/client/package-lock.json b/client/package-lock.json index 504fe26..82baa15 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -2434,6 +2434,53 @@ "react-lifecycles-compat": "^3.0.4" } }, + "@redux-saga/core": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@redux-saga/core/-/core-1.1.3.tgz", + "integrity": "sha512-8tInBftak8TPzE6X13ABmEtRJGjtK17w7VUs7qV17S8hCO5S3+aUTWZ/DBsBJPdE8Z5jOPwYALyvofgq1Ws+kg==", + "requires": { + "@babel/runtime": "^7.6.3", + "@redux-saga/deferred": "^1.1.2", + "@redux-saga/delay-p": "^1.1.2", + "@redux-saga/is": "^1.1.2", + "@redux-saga/symbols": "^1.1.2", + "@redux-saga/types": "^1.1.0", + "redux": "^4.0.4", + "typescript-tuple": "^2.2.1" + } + }, + "@redux-saga/deferred": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@redux-saga/deferred/-/deferred-1.1.2.tgz", + "integrity": "sha512-908rDLHFN2UUzt2jb4uOzj6afpjgJe3MjICaUNO3bvkV/kN/cNeI9PMr8BsFXB/MR8WTAZQq/PlTq8Kww3TBSQ==" + }, + "@redux-saga/delay-p": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@redux-saga/delay-p/-/delay-p-1.1.2.tgz", + "integrity": "sha512-ojc+1IoC6OP65Ts5+ZHbEYdrohmIw1j9P7HS9MOJezqMYtCDgpkoqB5enAAZrNtnbSL6gVCWPHaoaTY5KeO0/g==", + "requires": { + "@redux-saga/symbols": "^1.1.2" + } + }, + "@redux-saga/is": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@redux-saga/is/-/is-1.1.2.tgz", + "integrity": "sha512-OLbunKVsCVNTKEf2cH4TYyNbbPgvmZ52iaxBD4I1fTif4+MTXMa4/Z07L83zW/hTCXwpSZvXogqMqLfex2Tg6w==", + "requires": { + "@redux-saga/symbols": "^1.1.2", + "@redux-saga/types": "^1.1.0" + } + }, + "@redux-saga/symbols": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@redux-saga/symbols/-/symbols-1.1.2.tgz", + "integrity": "sha512-EfdGnF423glv3uMwLsGAtE6bg+R9MdqlHEzExnfagXPrIiuxwr3bdiAwz3gi+PsrQ3yBlaBpfGLtDG8rf3LgQQ==" + }, + "@redux-saga/types": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redux-saga/types/-/types-1.1.0.tgz", + "integrity": "sha512-afmTuJrylUU/0OtqzaRkbyYFFNgCF73Bvel/sw90pvGrWIZ+vyoIJqA6eMSoA6+nb443kTmulmBtC9NerXboNg==" + }, "@rollup/plugin-node-resolve": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-7.1.3.tgz", @@ -4690,6 +4737,11 @@ "integrity": "sha512-TbH79tcyi9FHwbyboOKeRachRq63mSuWYXOflsNO9ZyE5ClQ/JaozNKl+aWUq87qPNsXasXxi2AbgfwIJ+8GQw==", "dev": true }, + "@types/component-emitter": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@types/component-emitter/-/component-emitter-1.2.10.tgz", + "integrity": "sha512-bsjleuRKWmGqajMerkzox19aGbscQX5rmmvvXl3wlIp5gMG1HgkiwPxsN5p070fBDKTNSPgojVbuY1+HWMbFhg==" + }, "@types/eslint": { "version": "7.2.5", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-7.2.5.tgz", @@ -6801,6 +6853,11 @@ "resolved": "https://registry.npmjs.org/babylon/-/babylon-6.18.0.tgz", "integrity": "sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==" }, + "backo2": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz", + "integrity": "sha1-MasayLEpNjRj41s+u2n038+6eUc=" + }, "bail": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/bail/-/bail-1.0.5.tgz", @@ -6862,6 +6919,11 @@ } } }, + "base64-arraybuffer": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.4.tgz", + "integrity": "sha1-mBjHngWbE1X5fgQooBfIOOkLqBI=" + }, "base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -7544,8 +7606,7 @@ "classnames": { "version": "2.2.6", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz", - "integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==", - "dev": true + "integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==" }, "clean-css": { "version": "4.2.3", @@ -7799,6 +7860,11 @@ "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=" }, + "component-bind": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/component-bind/-/component-bind-1.0.0.tgz", + "integrity": "sha1-AMYIq33Nk4l8AAllGx06jh5zu9E=" + }, "component-emitter": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", @@ -9371,6 +9437,46 @@ "objectorarray": "^1.0.4" } }, + "engine.io-client": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-4.0.5.tgz", + "integrity": "sha512-1lkn0QdekHQPMTcxUh8LqIuxQHNtKV5GvqkQzmZ1rYKAvB6puMm13U7K1ps3OQZ4joE46asQiAKrcdL9weNEVw==", + "requires": { + "base64-arraybuffer": "0.1.4", + "component-emitter": "~1.3.0", + "debug": "~4.1.0", + "engine.io-parser": "~4.0.1", + "has-cors": "1.1.0", + "parseqs": "0.0.6", + "parseuri": "0.0.6", + "ws": "~7.2.1", + "xmlhttprequest-ssl": "~1.5.4", + "yeast": "0.1.2" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "requires": { + "ms": "^2.1.1" + } + }, + "ws": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.2.5.tgz", + "integrity": "sha512-C34cIU4+DB2vMyAbmEKossWq2ZQDr6QEyuuCzWrM9zfw1sGc0mYiJ0UnG9zzNykt49C2Fi34hvr2vssFQRS6EA==" + } + } + }, + "engine.io-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-4.0.2.tgz", + "integrity": "sha512-sHfEQv6nmtJrq6TKuIz5kyEKH/qSdK56H/A+7DnAuUPWosnIZAS2NHNcPLmyjtY3cGS/MqJdZbUjW97JU72iYg==", + "requires": { + "base64-arraybuffer": "0.1.4" + } + }, "enhanced-resolve": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-4.3.0.tgz", @@ -11598,6 +11704,11 @@ } } }, + "has-cors": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-cors/-/has-cors-1.1.0.tgz", + "integrity": "sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk=" + }, "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -12440,7 +12551,6 @@ "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "dev": true, "requires": { "loose-envify": "^1.0.0" } @@ -14744,6 +14854,16 @@ "resolved": "https://registry.npmjs.org/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz", "integrity": "sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=" }, + "lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=" + }, + "lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" + }, "lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -16165,6 +16285,16 @@ "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==" }, + "parseqs": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/parseqs/-/parseqs-0.0.6.tgz", + "integrity": "sha512-jeAGzMDbfSHHA091hr0r31eYfTig+29g3GKKE/PPbEQ65X0lmMwlEoqmhzu0iztID5uJpZsFlUPDP8ThPL7M8w==" + }, + "parseuri": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/parseuri/-/parseuri-0.0.6.tgz", + "integrity": "sha512-AUjen8sAkGgao7UyCX6Ahv0gIK2fABKmYjvP4xmy5JaKvcbTRueIqIPHLAfq30xJddqSE033IOMUSOMCcK3Sow==" + }, "parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -18036,6 +18166,25 @@ } } }, + "react-emoji-render": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/react-emoji-render/-/react-emoji-render-1.2.4.tgz", + "integrity": "sha512-AqktVXV38uDpgf02BoCXrzLYFsHAsxfdWwjrLexSJ22l1JgB01y1KejjxW/zTuCzod6O7BZfiMS866LEEfMHmA==", + "requires": { + "classnames": "^2.2.5", + "emoji-regex": "^6.4.1", + "lodash.flatten": "^4.4.0", + "prop-types": "^15.5.8", + "string-replace-to-array": "^1.0.1" + }, + "dependencies": { + "emoji-regex": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-6.5.1.tgz", + "integrity": "sha512-PAHp6TxrCy7MGMFidro8uikr+zlJJKJ/Q6mm2ExZ7HwkyR9lSVFfE3kt36qcwa24BQL7y0G9axycGjK1A/0uNQ==" + } + } + }, "react-error-overlay": { "version": "6.0.8", "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.8.tgz", @@ -18507,6 +18656,14 @@ "symbol-observable": "^1.2.0" } }, + "redux-saga": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/redux-saga/-/redux-saga-1.1.3.tgz", + "integrity": "sha512-RkSn/z0mwaSa5/xH/hQLo8gNf4tlvT18qXDNvedihLcfzh+jMchDgaariQoehCpgRltEm4zHKJyINEz6aqswTw==", + "requires": { + "@redux-saga/core": "^1.1.3" + } + }, "refractor": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/refractor/-/refractor-3.2.0.tgz", @@ -19914,6 +20071,51 @@ } } }, + "socket.io-client": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-3.0.4.tgz", + "integrity": "sha512-qMvBuS+W9JIN2mkfAWDCxuIt+jpIKDf8C0604zEqx1JrPaPSS6cN0F3B2GYWC83TqBeVJXW66GFxWV3KD88n0Q==", + "requires": { + "@types/component-emitter": "^1.2.10", + "backo2": "1.0.2", + "component-bind": "1.0.0", + "component-emitter": "~1.3.0", + "debug": "~4.1.0", + "engine.io-client": "~4.0.0", + "parseuri": "0.0.6", + "socket.io-parser": "~4.0.1" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "requires": { + "ms": "^2.1.1" + } + } + } + }, + "socket.io-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.0.2.tgz", + "integrity": "sha512-Bs3IYHDivwf+bAAuW/8xwJgIiBNtlvnjYRc4PbXgniLmcP1BrakBoq/QhO24rgtgW7VZ7uAaswRGxutUnlAK7g==", + "requires": { + "@types/component-emitter": "^1.2.10", + "component-emitter": "~1.3.0", + "debug": "~4.1.0" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "requires": { + "ms": "^2.1.1" + } + } + } + }, "sockjs": { "version": "0.3.20", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.20.tgz", @@ -20292,6 +20494,16 @@ "resolved": "https://registry.npmjs.org/string-natural-compare/-/string-natural-compare-3.0.1.tgz", "integrity": "sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==" }, + "string-replace-to-array": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/string-replace-to-array/-/string-replace-to-array-1.0.3.tgz", + "integrity": "sha1-yT66mZpe4k1zGuu69auja18Y978=", + "requires": { + "invariant": "^2.2.1", + "lodash.flatten": "^4.2.0", + "lodash.isstring": "^4.0.1" + } + }, "string-width": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", @@ -21291,6 +21503,27 @@ "integrity": "sha512-ywmr/VrTVCmNTJ6iV2LwIrfG1P+lv6luD8sUJs+2eI9NLGigaN+nUQc13iHqisq7bra9lnmUSYqbJvegraBOPQ==", "dev": true }, + "typescript-compare": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/typescript-compare/-/typescript-compare-0.0.2.tgz", + "integrity": "sha512-8ja4j7pMHkfLJQO2/8tut7ub+J3Lw2S3061eJLFQcvs3tsmJKp8KG5NtpLn7KcY2w08edF74BSVN7qJS0U6oHA==", + "requires": { + "typescript-logic": "^0.0.0" + } + }, + "typescript-logic": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/typescript-logic/-/typescript-logic-0.0.0.tgz", + "integrity": "sha512-zXFars5LUkI3zP492ls0VskH3TtdeHCqu0i7/duGt60i5IGPIpAHE/DWo5FqJ6EjQ15YKXrt+AETjv60Dat34Q==" + }, + "typescript-tuple": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/typescript-tuple/-/typescript-tuple-2.2.1.tgz", + "integrity": "sha512-Zcr0lbt8z5ZdEzERHAMAniTiIKerFCMgd7yjq1fPnDJ43et/k9twIFQMUYff9k5oXcsQ0WpvFcgzK2ZKASoW6Q==", + "requires": { + "typescript-compare": "^0.0.2" + } + }, "unfetch": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.2.0.tgz", @@ -23548,6 +23781,11 @@ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" }, + "xmlhttprequest-ssl": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz", + "integrity": "sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4=" + }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -23624,6 +23862,11 @@ } } }, + "yeast": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/yeast/-/yeast-0.1.2.tgz", + "integrity": "sha1-AI4G2AlDIMNy28L47XagymyKxBk=" + }, "zwitch": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz", diff --git a/client/package.json b/client/package.json index 1a0e3c9..e6b9152 100644 --- a/client/package.json +++ b/client/package.json @@ -35,7 +35,6 @@ "babel-loader": "^8.2.1", "clean-webpack-plugin": "^3.0.0", "core-js": "^3.7.0", - "dotenv": "^8.2.0", "eslint": "^7.13.0", "eslint-config-airbnb": "^18.2.1", "eslint-config-prettier": "^6.15.0", @@ -52,13 +51,17 @@ "dependencies": { "@types/react-redux": "^7.1.11", "axios": "^0.21.0", + "dotenv": "^8.2.0", "react": "^17.0.1", "react-dom": "^17.0.1", + "react-emoji-render": "^1.2.4", "react-redux": "^7.2.2", "react-router-dom": "^5.2.0", "react-scripts": "^4.0.0", "redux": "^4.0.5", + "redux-saga": "^1.1.3", "remarkable": "^2.0.1", + "socket.io-client": "^3.0.4", "styled-components": "^5.2.1" } } diff --git a/client/public/imgs/close-filled-icon.png b/client/public/imgs/close-filled-icon.png new file mode 100644 index 0000000..df8f601 Binary files /dev/null and b/client/public/imgs/close-filled-icon.png differ diff --git a/client/public/imgs/detail-icon.png b/client/public/imgs/detail-icon.png new file mode 100644 index 0000000..53ab505 Binary files /dev/null and b/client/public/imgs/detail-icon.png differ diff --git a/client/public/imgs/emoji-icon.png b/client/public/imgs/emoji-icon.png new file mode 100644 index 0000000..f634ea8 Binary files /dev/null and b/client/public/imgs/emoji-icon.png differ diff --git a/client/public/imgs/filter-icon.png b/client/public/imgs/filter-icon.png new file mode 100644 index 0000000..9f9a989 Binary files /dev/null and b/client/public/imgs/filter-icon.png differ diff --git a/client/public/imgs/option-icon.png b/client/public/imgs/option-icon.png new file mode 100644 index 0000000..7038279 Binary files /dev/null and b/client/public/imgs/option-icon.png differ diff --git a/client/public/imgs/plus-icon.png b/client/public/imgs/plus-icon.png new file mode 100644 index 0000000..14ba977 Binary files /dev/null and b/client/public/imgs/plus-icon.png differ diff --git a/client/public/imgs/search-icon.png b/client/public/imgs/search-icon.png new file mode 100644 index 0000000..4a5b933 Binary files /dev/null and b/client/public/imgs/search-icon.png differ diff --git a/client/public/imgs/sort-icon.png b/client/public/imgs/sort-icon.png new file mode 100644 index 0000000..7777fe4 Binary files /dev/null and b/client/public/imgs/sort-icon.png differ diff --git a/client/public/imgs/thread-icon.png b/client/public/imgs/thread-icon.png new file mode 100644 index 0000000..69bed73 Binary files /dev/null and b/client/public/imgs/thread-icon.png differ diff --git a/client/public/imgs/user-icon.png b/client/public/imgs/user-icon.png new file mode 100644 index 0000000..9f7fd2e Binary files /dev/null and b/client/public/imgs/user-icon.png differ diff --git a/client/src/common/constants/chatroom-event-type.ts b/client/src/common/constants/chatroom-event-type.ts new file mode 100644 index 0000000..412920f --- /dev/null +++ b/client/src/common/constants/chatroom-event-type.ts @@ -0,0 +1,6 @@ +export const ChatroomEventType = { + COMMON: 'Common', + LOADING: 'Loading', + COMPLETELOADING: 'Complete loading', + INPUTTEXT: 'Input Text' +}; diff --git a/client/src/common/constants/default-section-name.ts b/client/src/common/constants/default-section-name.ts new file mode 100644 index 0000000..6a053bb --- /dev/null +++ b/client/src/common/constants/default-section-name.ts @@ -0,0 +1,7 @@ +const DefaultSectionName = { + CHANNELS: 'Channels', + DIRECT_MESSAGES: 'Direct Messages', + STARRED: 'Starred' +}; + +export { DefaultSectionName }; diff --git a/client/src/common/constants/index.ts b/client/src/common/constants/index.ts new file mode 100644 index 0000000..3426714 --- /dev/null +++ b/client/src/common/constants/index.ts @@ -0,0 +1,5 @@ +import { DefaultSectionName } from './default-section-name'; +import { KeyCode } from './key-code'; +import { ChatroomEventType } from './chatroom-event-type'; + +export { DefaultSectionName, KeyCode, ChatroomEventType }; diff --git a/client/src/common/constants/key-code.ts b/client/src/common/constants/key-code.ts new file mode 100644 index 0000000..828c761 --- /dev/null +++ b/client/src/common/constants/key-code.ts @@ -0,0 +1,5 @@ +const KeyCode = { + ENTER: 13 +}; + +export { KeyCode }; diff --git a/client/src/common/dispatch/chatroom-dispatch.ts b/client/src/common/dispatch/chatroom-dispatch.ts deleted file mode 100644 index 9035f3f..0000000 --- a/client/src/common/dispatch/chatroom-dispatch.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { api } from '@utils/index'; -import store from '@store/index'; - -const getChatroomInfo = async (selectedChatroomId: number) => { - const chatroomInfo = await api.getChatroom(selectedChatroomId); - store.dispatch({ type: 'LOAD', ...chatroomInfo }); -}; - -export { getChatroomInfo }; diff --git a/client/src/common/dispatch/index.ts b/client/src/common/dispatch/index.ts deleted file mode 100644 index 3b18b44..0000000 --- a/client/src/common/dispatch/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { getUserInfo } from './user-dispatch'; -import { getUserChatroom } from './user-chatroom-dispatch'; -import { getChatroomInfo } from './chatroom-dispatch'; - -export { getUserInfo, getUserChatroom, getChatroomInfo }; diff --git a/client/src/common/dispatch/user-chatroom-dispatch.ts b/client/src/common/dispatch/user-chatroom-dispatch.ts deleted file mode 100644 index 86e590d..0000000 --- a/client/src/common/dispatch/user-chatroom-dispatch.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { api } from '@utils/index'; -import store from '@store/index'; - -const getUserChatroom = async () => { - const userChatroom = await api.getUserChatroom(); - store.dispatch({ type: 'INITSIDEBAR', ...userChatroom }); -}; - -export { getUserChatroom }; diff --git a/client/src/common/dispatch/user-dispatch.ts b/client/src/common/dispatch/user-dispatch.ts deleted file mode 100644 index 0c57b52..0000000 --- a/client/src/common/dispatch/user-dispatch.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { api } from '@utils/index'; -import store from '@store/index'; - -const getUserInfo = async () => { - const userInfo = await api.getUserInfo(); - store.dispatch({ type: 'LOGIN', ...userInfo }); -}; - -export { getUserInfo }; diff --git a/client/src/common/reducer/chatroom-reducer.ts b/client/src/common/reducer/chatroom-reducer.ts deleted file mode 100644 index 859a05d..0000000 --- a/client/src/common/reducer/chatroom-reducer.ts +++ /dev/null @@ -1,29 +0,0 @@ -const initialState = { - chatType: '', - description: '', - isPrivate: false, - title: '', - topic: null, - userCount: 0, - users: [] -}; - -const chatroomReducer = (state = initialState, action: any) => { - switch (action.type) { - case 'LOAD': - return { - ...state, - chatType: action.chatType, - description: action.description, - isPrivate: action.isPrivate, - title: action.title, - topic: action.topic, - userCount: action.userCount, - users: action.users - }; - default: - return state; - } -}; - -export default chatroomReducer; diff --git a/client/src/common/reducer/rootReducer.ts b/client/src/common/reducer/rootReducer.ts deleted file mode 100644 index 61ba40d..0000000 --- a/client/src/common/reducer/rootReducer.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { combineReducers } from 'redux'; -import userReducer from './user-reducer'; -import sidebarReducer from './sidebar-reducer'; -import chatroomReducer from './chatroom-reducer'; - -const rootReducer = combineReducers({ - userData: userReducer, - sidebarData: sidebarReducer, - chatroomData: chatroomReducer -}); - -export default rootReducer; diff --git a/client/src/common/reducer/sidebar-reducer.ts b/client/src/common/reducer/sidebar-reducer.ts deleted file mode 100644 index 226160d..0000000 --- a/client/src/common/reducer/sidebar-reducer.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { uriParser } from '@utils/index'; - -const initialState = { - starred: [], - otherSections: [], - channels: [], - directMessages: [], - selectedChatroomId: uriParser.getChatroomId() -}; - -const sidebarReducer = (state = initialState, action: any) => { - switch (action.type) { - case 'INITSIDEBAR': - return { - ...state, - starred: action.starred, - otherSections: action.otherSections, - channels: action.channels, - directMessages: action.directMessages - }; - case 'UPDATESIDEBAR': - return { - ...state, - selectedChatroomId: action.selectedChatroomId - }; - default: - return state; - } -}; - -export default sidebarReducer; diff --git a/client/src/common/socket/emits/message.ts b/client/src/common/socket/emits/message.ts new file mode 100644 index 0000000..781a31b --- /dev/null +++ b/client/src/common/socket/emits/message.ts @@ -0,0 +1,6 @@ +import { CREATE_MESSAGE, createMessageState } from '@socket/types/message-types'; +import socket from '../socketIO'; + +export const createMessage = (message: createMessageState) => { + socket.emit(CREATE_MESSAGE, message); +}; diff --git a/client/src/common/socket/socketIO.ts b/client/src/common/socket/socketIO.ts new file mode 100644 index 0000000..43be4a4 --- /dev/null +++ b/client/src/common/socket/socketIO.ts @@ -0,0 +1,15 @@ +import { io } from 'socket.io-client'; + +const socketURL: any = process.env.API_URL; + +const socket = io(socketURL, { + transportOptions: { + polling: { + extraHeaders: { + Authorization: window.localStorage.getItem('token') + } + } + } +}); + +export default socket; diff --git a/client/src/common/socket/types/message-types.ts b/client/src/common/socket/types/message-types.ts new file mode 100644 index 0000000..dd3e904 --- /dev/null +++ b/client/src/common/socket/types/message-types.ts @@ -0,0 +1,6 @@ +export const CREATE_MESSAGE = 'create message'; + +export interface createMessageState { + content: string; + chatroomId: number | null; +} diff --git a/client/src/common/store/actions/chatroom-action.ts b/client/src/common/store/actions/chatroom-action.ts new file mode 100644 index 0000000..1303614 --- /dev/null +++ b/client/src/common/store/actions/chatroom-action.ts @@ -0,0 +1,15 @@ +import { + LOAD_ASYNC, + INIT_SIDEBAR_ASYNC, + PICK_CHANNEL_ASYNC, + INSERT_MESSAGE, + ADD_CHANNEL_ASYNC, + LOAD_NEXT_MESSAGES_ASYNC +} from '../types/chatroom-types'; + +export const loadAsync = (payload: any) => ({ type: LOAD_ASYNC, payload }); +export const initSidebarAsync = () => ({ type: INIT_SIDEBAR_ASYNC }); +export const pickChannel = (payload: any) => ({ type: PICK_CHANNEL_ASYNC, payload }); +export const insertMessage = (payload: any) => ({ type: INSERT_MESSAGE, payload }); +export const addChannel = (payload: any) => ({ type: ADD_CHANNEL_ASYNC, payload }); +export const loadNextMessages = (payload: any) => ({ type: LOAD_NEXT_MESSAGES_ASYNC, payload }); diff --git a/client/src/common/store/actions/modal-action.ts b/client/src/common/store/actions/modal-action.ts new file mode 100644 index 0000000..07a50e5 --- /dev/null +++ b/client/src/common/store/actions/modal-action.ts @@ -0,0 +1,6 @@ +import { CREATE_MODAL_OPEN, CREATE_MODAL_CLOSE, CHANNEL_MODAL_OPEN, CHANNEL_MODAL_CLOSE } from '@store/types/modal-types'; + +export const createModalOpen = () => ({ type: CREATE_MODAL_OPEN }); +export const createModalClose = () => ({ type: CREATE_MODAL_CLOSE }); +export const channelModalOpen = (payload: any) => ({ type: CHANNEL_MODAL_OPEN, payload }); +export const channelModalClose = () => ({ type: CHANNEL_MODAL_CLOSE }); diff --git a/client/src/common/store/actions/user-action.ts b/client/src/common/store/actions/user-action.ts new file mode 100644 index 0000000..d63b697 --- /dev/null +++ b/client/src/common/store/actions/user-action.ts @@ -0,0 +1,4 @@ +import { LOGIN_ASYNC, LOGOUT } from '../types/user-types'; + +export const loginAsync = () => ({ type: LOGIN_ASYNC }); +export const logout = () => ({ type: LOGOUT }); diff --git a/client/src/common/store/index.ts b/client/src/common/store/index.ts index 1c97bc8..378fbd9 100644 --- a/client/src/common/store/index.ts +++ b/client/src/common/store/index.ts @@ -1,4 +1,10 @@ -import { createStore } from 'redux'; -import rootReducer from '../reducer/rootReducer'; +import { createStore, applyMiddleware } from 'redux'; +import createSagaMiddleware from 'redux-saga'; +import { rootReducer } from './reducers'; +import { rootSaga } from './sagas'; -export default createStore(rootReducer); +const sagaMiddleware = createSagaMiddleware(); + +export default createStore(rootReducer, applyMiddleware(sagaMiddleware)); + +sagaMiddleware.run(rootSaga); diff --git a/client/src/common/store/reducers/chatroom-reducer.ts b/client/src/common/store/reducers/chatroom-reducer.ts new file mode 100644 index 0000000..f72dc59 --- /dev/null +++ b/client/src/common/store/reducers/chatroom-reducer.ts @@ -0,0 +1,79 @@ +/* eslint-disable no-case-declarations */ +import { uriParser } from '@utils/index'; +import { + chatroomState, + LOAD, + ChatroomTypes, + PICK_CHANNEL, + INIT_SIDEBAR, + INSERT_MESSAGE, + ADD_CHANNEL, + LOAD_NEXT_MESSAGES +} from '../types/chatroom-types'; + +const initialState: chatroomState = { + selectedChatroom: { + chatType: '', + description: '', + isPrivate: false, + title: '', + topic: '', + userCount: 0, + users: [] + }, + starred: [], + otherSections: [], + channels: [], + directMessages: [], + selectedChatroomId: uriParser.getChatroomId(), + messages: [] +}; + +export default function chatroomReducer(state = initialState, action: ChatroomTypes) { + switch (action.type) { + case LOAD: + return { + ...state, + selectedChatroom: action.payload.selectedChatroom, + messages: action.payload.messages + }; + case INIT_SIDEBAR: + return { + ...state, + starred: action.payload.starred, + otherSections: action.payload.otherSections, + channels: action.payload.channels, + directMessages: action.payload.directMessages + }; + case PICK_CHANNEL: + return { + ...state, + selectedChatroom: action.payload.chatroom, + messages: action.payload.messages, + selectedChatroomId: action.payload.selectedChatroomId + }; + case INSERT_MESSAGE: + const newMessages = state.messages; + if (action.payload.chatroomId === state.selectedChatroomId) newMessages.push(action.payload); + return { + ...state, + messages: newMessages + }; + case ADD_CHANNEL: + const newChannels = state.channels; + newChannels.push(action.payload); + return { + ...state, + channels: newChannels + }; + case LOAD_NEXT_MESSAGES: + const nextMessages = action.payload.messages; + nextMessages.push(...state.messages); + return { + ...state, + messages: nextMessages + }; + default: + return state; + } +} diff --git a/client/src/common/store/reducers/index.ts b/client/src/common/store/reducers/index.ts new file mode 100644 index 0000000..8d445b2 --- /dev/null +++ b/client/src/common/store/reducers/index.ts @@ -0,0 +1,12 @@ +import { combineReducers } from 'redux'; +import userReducer from './user-reducer'; +import chatroomReducer from './chatroom-reducer'; +import modalReducer from './modal-reducer'; + +export const rootReducer = combineReducers({ + user: userReducer, + chatroom: chatroomReducer, + modal: modalReducer +}); + +export type RootState = ReturnType; diff --git a/client/src/common/store/reducers/modal-reducer.ts b/client/src/common/store/reducers/modal-reducer.ts new file mode 100644 index 0000000..0eb354d --- /dev/null +++ b/client/src/common/store/reducers/modal-reducer.ts @@ -0,0 +1,23 @@ +import { ModalState, ModalTypes, CREATE_MODAL_OPEN, CREATE_MODAL_CLOSE, CHANNEL_MODAL_OPEN, CHANNEL_MODAL_CLOSE } from '@store/types/modal-types'; + +const initialState: ModalState = { + createModal: { isOpen: false }, + channelModal: { isOpen: false, x: 0, y: 0 } +}; + +const ModalReducer = (state = initialState, action: ModalTypes) => { + switch (action.type) { + case CREATE_MODAL_OPEN: + return { ...state, createModal: { isOpen: true }, channelModal: { isOpen: false } }; + case CREATE_MODAL_CLOSE: + return { ...state, createModal: { isOpen: false } }; + case CHANNEL_MODAL_OPEN: + return { ...state, channelModal: { isOpen: true, x: action.payload.x, y: action.payload.y } }; + case CHANNEL_MODAL_CLOSE: + return { ...state, channelModal: { isOpen: false } }; + default: + return state; + } +}; + +export default ModalReducer; diff --git a/client/src/common/reducer/user-reducer.ts b/client/src/common/store/reducers/user-reducer.ts similarity index 52% rename from client/src/common/reducer/user-reducer.ts rename to client/src/common/store/reducers/user-reducer.ts index fa3f167..4b6fc2c 100644 --- a/client/src/common/reducer/user-reducer.ts +++ b/client/src/common/store/reducers/user-reducer.ts @@ -1,13 +1,15 @@ -const initialState = { +import { userState, UserTypes } from '@store/types/user-types'; + +const initialState: userState = { userId: null, profileUri: '', displayName: '' }; -const UserReducer = (state = initialState, action: any) => { +const UserReducer = (state = initialState, action: UserTypes) => { switch (action.type) { case 'LOGIN': - return { ...state, userId: action.userId, profileUri: action.profileUri, displayName: action.displayName }; + return { ...state, ...action.payload }; case 'LOGOUT': return { ...state, ...initialState }; default: diff --git a/client/src/common/store/sagas/chatroom-saga.ts b/client/src/common/store/sagas/chatroom-saga.ts new file mode 100644 index 0000000..eca1af4 --- /dev/null +++ b/client/src/common/store/sagas/chatroom-saga.ts @@ -0,0 +1,78 @@ +import { call, put, takeEvery } from 'redux-saga/effects'; +import API from '@utils/api'; +import { + LOAD, + LOAD_ASYNC, + INIT_SIDEBAR, + INIT_SIDEBAR_ASYNC, + PICK_CHANNEL, + PICK_CHANNEL_ASYNC, + ADD_CHANNEL_ASYNC, + ADD_CHANNEL, + LOAD_NEXT_MESSAGES, + LOAD_NEXT_MESSAGES_ASYNC +} from '../types/chatroom-types'; + +function* loadSaga(action: any) { + try { + const payload = yield call(API.getChatroom, action.payload.selectedChatroomId); + const messages = yield call(API.getMessages, action.payload.selectedChatroomId); + yield put({ type: LOAD, payload: { selectedChatroom: payload, messages } }); + } catch (e) { + console.log(e); + } +} + +function* initSidebarSaga() { + try { + const payload = yield call(API.getUserChatroom); + yield put({ type: INIT_SIDEBAR, payload }); + } catch (e) { + console.log(e); + } +} + +function* pickChannelSaga(action: any) { + try { + const chatroom = yield call(API.getChatroom, action.payload.selectedChatroomId); + const messages = yield call(API.getMessages, action.payload.selectedChatroomId); + yield put({ + type: PICK_CHANNEL, + payload: { + chatroom, + messages, + selectedChatroomId: action.payload.selectedChatroomId + } + }); + } catch (e) { + console.log(e); + } +} + +function* addChannel(action: any) { + try { + const chatroomId = yield call(API.createChannel, action.payload.title, action.payload.description, action.payload.isPrivate); + const payload = { chatroomId, chatType: 'Channel', isPrivate: action.payload.isPrivate, title: action.payload.title }; + yield put({ type: ADD_CHANNEL, payload }); + } catch (e) { + alert('같은 이름의 채널이 존재합니다.'); + } +} + +function* loadNextMessages(action: any) { + try { + const offsetId = action.payload.offsetMessage.messageId; + const nextMessages = yield call(API.getNextMessages, action.payload.chatRoomId, offsetId); + yield put({ type: LOAD_NEXT_MESSAGES, payload: { messages: nextMessages } }); + } catch (e) { + console.log(e); + } +} + +export function* chatroomSaga() { + yield takeEvery(LOAD_ASYNC, loadSaga); + yield takeEvery(INIT_SIDEBAR_ASYNC, initSidebarSaga); + yield takeEvery(PICK_CHANNEL_ASYNC, pickChannelSaga); + yield takeEvery(ADD_CHANNEL_ASYNC, addChannel); + yield takeEvery(LOAD_NEXT_MESSAGES_ASYNC, loadNextMessages); +} diff --git a/client/src/common/store/sagas/index.ts b/client/src/common/store/sagas/index.ts new file mode 100644 index 0000000..1f30fea --- /dev/null +++ b/client/src/common/store/sagas/index.ts @@ -0,0 +1,7 @@ +import { all } from 'redux-saga/effects'; +import { chatroomSaga } from './chatroom-saga'; +import { userSaga } from './user-saga'; + +export function* rootSaga() { + yield all([chatroomSaga(), userSaga()]); +} diff --git a/client/src/common/store/sagas/user-saga.ts b/client/src/common/store/sagas/user-saga.ts new file mode 100644 index 0000000..1e70394 --- /dev/null +++ b/client/src/common/store/sagas/user-saga.ts @@ -0,0 +1,16 @@ +import { call, put, takeEvery } from 'redux-saga/effects'; +import API from '@utils/api'; +import { LOGIN, LOGIN_ASYNC } from '../types/user-types'; + +function* userLoginSaga() { + try { + const payload = yield call(API.getUserInfo); + yield put({ type: LOGIN, payload }); + } catch (e) { + console.log(e); + } +} + +export function* userSaga() { + yield takeEvery(LOGIN_ASYNC, userLoginSaga); +} diff --git a/client/src/common/store/types/chatroom-types.ts b/client/src/common/store/types/chatroom-types.ts new file mode 100644 index 0000000..f478b46 --- /dev/null +++ b/client/src/common/store/types/chatroom-types.ts @@ -0,0 +1,86 @@ +export const LOAD = 'LOAD'; +export const LOAD_ASYNC = 'LOAD_ASYNC'; +export const INIT_SIDEBAR = 'INIT_SIDEBAR'; +export const INIT_SIDEBAR_ASYNC = 'INIT_SIDEBAR_ASYNC'; +export const PICK_CHANNEL = 'PICK_CHANNEL'; +export const PICK_CHANNEL_ASYNC = 'PICK_CHANNEL_ASYNC'; +export const INSERT_MESSAGE = 'INSERT_MESSAGE'; +export const ADD_CHANNEL = 'ADD_CHANNEL'; +export const ADD_CHANNEL_ASYNC = 'ADD_CHANNEL_ASYNC'; +export const LOAD_NEXT_MESSAGES = 'LOAD_NEXT_MESSAGES'; +export const LOAD_NEXT_MESSAGES_ASYNC = 'LOAD_NEXT_MESSAGES_ASYNC'; + +export interface selectedChatroomState { + chatType: string; + description?: string; + isPrivate: boolean; + title: string; + topic?: string; + userCount: number; + users: Array; +} + +export interface chatroomState { + selectedChatroom: selectedChatroomState; + messages: Array; + starred: Array; + otherSections: Array; + channels: Array; + directMessages: Array; + selectedChatroomId: number | null; +} + +export interface sidebarState { + starred: Array; + otherSections: Array; + channels: Array; + directMessages: Array; + selectedChatroomId: number | null; +} + +export interface channelState { + chatroom: selectedChatroomState; + messages: Array; + selectedChatroomId: number; +} + +export interface messageState { + message: any; + chatroomId: number; +} + +export interface messagesState { + messages: Array; +} + +interface LoadChatroomAction { + type: typeof LOAD; + payload: chatroomState; +} + +interface InitSidebarAction { + type: typeof INIT_SIDEBAR; + payload: sidebarState; +} + +interface PickChannelAction { + type: typeof PICK_CHANNEL; + payload: channelState; +} + +interface InsertMessageAction { + type: typeof INSERT_MESSAGE; + payload: messageState; +} + +interface AddChannelAction { + type: typeof ADD_CHANNEL; + payload: selectedChatroomState; +} + +interface LoadNextAction { + type: typeof LOAD_NEXT_MESSAGES; + payload: messagesState; +} + +export type ChatroomTypes = LoadChatroomAction | InitSidebarAction | PickChannelAction | InsertMessageAction | AddChannelAction | LoadNextAction; diff --git a/client/src/common/store/types/modal-types.ts b/client/src/common/store/types/modal-types.ts new file mode 100644 index 0000000..222ede5 --- /dev/null +++ b/client/src/common/store/types/modal-types.ts @@ -0,0 +1,41 @@ +export const CREATE_MODAL_OPEN = 'CREATE_MODAL_OPEN'; +export const CREATE_MODAL_CLOSE = 'CREATE_MODAL_CLOSE'; +export const CHANNEL_MODAL_OPEN = 'CHANNEL_MODAL_OPEN'; +export const CHANNEL_MODAL_CLOSE = 'CHANNEL_MODAL_CLOSE'; + +export interface CreateChannelModalState { + isOpen: boolean; +} + +export interface ChannelModalState { + isOpen: boolean; + x: number; + y: number; +} + +export interface ModalState { + createModal: CreateChannelModalState; + channelModal: ChannelModalState; +} + +interface CreateChannelModalOpenAction { + type: typeof CREATE_MODAL_OPEN; + payload: CreateChannelModalState; +} + +interface CreateChannelModalCloseAction { + type: typeof CREATE_MODAL_CLOSE; + payload: CreateChannelModalState; +} + +interface ChannelModalOpenAction { + type: typeof CHANNEL_MODAL_OPEN; + payload: ChannelModalState; +} + +interface ChannelModalCloseAction { + type: typeof CHANNEL_MODAL_CLOSE; + payload: ChannelModalState; +} + +export type ModalTypes = CreateChannelModalOpenAction | CreateChannelModalCloseAction | ChannelModalOpenAction | ChannelModalCloseAction; diff --git a/client/src/common/store/types/user-types.ts b/client/src/common/store/types/user-types.ts new file mode 100644 index 0000000..c4852c1 --- /dev/null +++ b/client/src/common/store/types/user-types.ts @@ -0,0 +1,20 @@ +export const LOGIN = 'LOGIN'; +export const LOGIN_ASYNC = 'LOGIN_ASYNC'; +export const LOGOUT = 'LOGOUT'; + +export interface userState { + userId: number | null; + profileUri: string; + displayName: string; +} + +interface userLoginAction { + type: typeof LOGIN; + payload: userState; +} + +interface userLogoutAction { + type: typeof LOGOUT; +} + +export type UserTypes = userLoginAction | userLogoutAction; diff --git a/client/src/common/theme/color.ts b/client/src/common/theme/color.ts index 7ff9ff6..405a080 100644 --- a/client/src/common/theme/color.ts +++ b/client/src/common/theme/color.ts @@ -2,25 +2,32 @@ const color = { primary: 'black', secondary: '#1a1d21', tertiary: 'white', + quaternary: 'rgb(246, 246, 246)', text_primary: 'rgb(198, 199, 200)', text_secondary: 'white', text_tertiary: 'rgba(147,147,147,1)', text_quaternary: 'rgba(83,83,83,1)', text_quinary: 'rgb(160, 158, 169)', + text_senary: 'rgb(98, 100, 136)', + text_septenary: '#1d9bd1', border_primary: '#e2e2e2', border_secondary: '#c6c6c6', + border_tertiary: '#1d9bd1', light_primary: '#33e600', box_shadow_primary: 'rgba(27, 31, 35, 0.075)', box_shadow_secondary: 'rgba(3, 102, 214, 0.3)', box_shadow_tertiary: 'rgba(255, 255, 255, 0.1)', modal_bg_outer_primary: 'rgba(0, 0, 0, 0.5)', modal_bg_inner_primary: 'white', + modal_bg_inner_secondary: 'rgba(248, 248, 248, 1)', selected_chatroom: '#0576b9', hover_primary: 'rgb(248, 248, 248)', + hover_secondary: 'rgb(18, 100, 163)', button_secondary: '#017a5a', button_tertiary: 'rgba(221,221,221,1)', sidebar_bg: '#1a1e22', - sidebar_border: '#313537' + sidebar_border: '#313537', + emoji_bg: 'rgb(232, 245, 250)' }; export default color; diff --git a/client/src/common/utils/api.ts b/client/src/common/utils/api.ts index e64e8b8..13fa4ab 100644 --- a/client/src/common/utils/api.ts +++ b/client/src/common/utils/api.ts @@ -1,30 +1,71 @@ import axios from 'axios'; +import { logout } from '@utils/index'; -axios.defaults.baseURL = 'http://127.0.0.1:3000'; +axios.defaults.baseURL = process.env.API_URL; axios.defaults.headers.common.Authorization = localStorage.getItem('token'); -const getToken = async (code: string | null) => { - const response = await axios.get(`/oauth/github/${code}`); - return response.headers.authorization; -}; +axios.interceptors.response.use( + (response) => { + return response; + }, + (error) => { + if (error.response.status === 401) logout(); + return Promise.reject(error); + } +); -const oauthLogin = async () => { - window.location.href = `${axios.defaults.baseURL}/oauth/github/login`; -}; +export default { + getToken: async (code: string | null) => { + const response = await axios.get(`/oauth/github/${code}`); + return response.headers.authorization; + }, -const getUserInfo = async () => { - const response = await axios.get('/api/auth'); - return response.data; -}; + oauthLogin: async () => { + window.location.href = `${axios.defaults.baseURL}/oauth/github/login`; + }, -const getUserChatroom = async () => { - const response = await axios.get('/api/user-chatrooms'); - return response.data; -}; + getUserInfo: async () => { + const response = await axios.get('/api/auth'); + return response.data; + }, -const getChatroom = async (id: number) => { - const response = await axios.get(`/api/chatrooms/${id}`); - return response.data; -}; + getUserChatroom: async () => { + const response = await axios.get('/api/user-chatrooms'); + return response.data; + }, + + getChatroom: async (id: number) => { + const response = await axios.get(`/api/chatrooms/${id}`); + return response.data; + }, -export { getToken, oauthLogin, getUserInfo, getUserChatroom, getChatroom }; + postMessage: async (data: any) => { + const response = await axios.post('api/messages', data); + return response.data; + }, + + getMessages: async (chatroomId: number) => { + const response = await axios.get(`api/messages/${chatroomId}`); + return response.data; + }, + + getMessage: async (chatroomId: number, messageId: number) => { + const response = await axios.get(`api/messages/${chatroomId}/${messageId}`); + return response.data; + }, + + getNextMessages: async (chatroomId: number, offsetId: number) => { + const response = await axios.get(`api/messages/${chatroomId}/${offsetId}`); + return response.data; + }, + + createChannel: async (title: string, description: string, isPrivate: boolean) => { + try { + const response = await axios.post(`api/chatrooms/channel`, { title, description, isPrivate }); + const { chatroomId } = response.data; + return chatroomId; + } catch (e) { + throw new Error('Channel creation failed.'); + } + } +}; diff --git a/client/src/common/utils/index.ts b/client/src/common/utils/index.ts index 8f13e19..3d833b4 100644 --- a/client/src/common/utils/index.ts +++ b/client/src/common/utils/index.ts @@ -1,6 +1,7 @@ -import * as api from './api'; +import API from './api'; import * as uriParser from './uriParser'; import { blockPage } from './blockPage'; import registerToken from './registerToken'; +import { logout } from './logout'; -export { api, uriParser, blockPage, registerToken }; +export { API, uriParser, blockPage, registerToken, logout }; diff --git a/client/src/common/utils/logout.ts b/client/src/common/utils/logout.ts new file mode 100644 index 0000000..bc3b3bc --- /dev/null +++ b/client/src/common/utils/logout.ts @@ -0,0 +1,4 @@ +export const logout = () => { + window.localStorage.removeItem('token'); + window.location.href = '/login'; +}; diff --git a/client/src/common/utils/registerToken.ts b/client/src/common/utils/registerToken.ts index acf7918..892eb67 100644 --- a/client/src/common/utils/registerToken.ts +++ b/client/src/common/utils/registerToken.ts @@ -1,9 +1,9 @@ -import { api, uriParser } from '@utils/index'; +import { API, uriParser } from '@utils/index'; const registerToken = async () => { if (uriParser.isExistParseCode()) { const code = uriParser.getCode(); - const token = await api.getToken(code); + const token = await API.getToken(code); if (token) { localStorage.setItem('token', token); window.location.href = '/'; diff --git a/client/src/common/utils/time.ts b/client/src/common/utils/time.ts new file mode 100644 index 0000000..ba46c08 --- /dev/null +++ b/client/src/common/utils/time.ts @@ -0,0 +1,32 @@ +const untisOfTime = { + seconds: 1, + minutes: 60, + hours: 60 * 60, + days: 24 * 60 * 60, + months: ((365 + 365 + 365 + 365 + 366) / 5 / 12) * 24 * 60 * 60, + years: ((365 + 365 + 365 + 365 + 366) / 5) * 24 * 60 * 60 +}; + +const timeAgo = (date: Date): string => { + const timeDiff = (new Date().getTime() - date.getTime()) / 1000; + + const minutes = timeDiff / untisOfTime.minutes; + const hours = timeDiff / untisOfTime.hours; + const days = timeDiff / untisOfTime.days; + const months = timeDiff / untisOfTime.months; + const years = timeDiff / untisOfTime.years; + + if (minutes < 1) return `1 minute ago`; + if (minutes < 60) return `${Math.round(minutes)} minute ago`; + if (hours < 24) return `${Math.round(hours)} hours ago`; + if (days < 31) return `${Math.round(days)} days ago`; + if (months < 12) return `${Math.round(months)} months ago`; + return `${Math.round(years)} years ago`; +}; + +const getTimeConversionValue = (date: Date): string => { + const time = new Date(date); + return time.toLocaleString('en-US', { hour: 'numeric', minute: 'numeric', hour12: true }); +}; + +export { timeAgo, getTimeConversionValue }; diff --git a/client/src/components/atoms/Button/Button.tsx b/client/src/components/atoms/Button/Button.tsx index ed006d2..2e951c8 100644 --- a/client/src/components/atoms/Button/Button.tsx +++ b/client/src/components/atoms/Button/Button.tsx @@ -1,5 +1,6 @@ import React from 'react'; import styled from 'styled-components'; +import { color } from '@theme/index'; interface ButtonProps { children: React.ReactNode; @@ -7,6 +8,7 @@ interface ButtonProps { borderColor: string; fontColor: string; isBold?: boolean; + hoverColor?: string; onClick?: () => void; } @@ -21,11 +23,18 @@ const StyledButton = styled.button` outline: none; cursor: pointer; font-weight: ${(props) => (props.isBold ? 'bold' : null)}; + ${(props) => (props.hoverColor ? `&:hover { background-color: ${color.hover_primary}}` : '')} `; -const Button: React.FC = ({ children, backgroundColor, borderColor, fontColor, isBold, ...props }) => { +const Button: React.FC = ({ children, backgroundColor, borderColor, fontColor, isBold, hoverColor, ...props }) => { return ( - + {children} ); diff --git a/client/src/components/atoms/DropMenuBox/DropMenuBox.stories.tsx b/client/src/components/atoms/DropMenuBox/DropMenuBox.stories.tsx new file mode 100644 index 0000000..7c56eb8 --- /dev/null +++ b/client/src/components/atoms/DropMenuBox/DropMenuBox.stories.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { Story, Meta } from '@storybook/react/types-6-0'; +import { DropMenuBox, DropMenuBoxProps } from './DropMenuBox'; + +export default { + title: 'atom/DropMenuBox', + component: DropMenuBox +} as Meta; + +const Template: Story = (args) => ; + +export const PrimaryDropMenuBox = Template.bind({}); +PrimaryDropMenuBox.args = {}; diff --git a/client/src/components/atoms/DropMenuBox/DropMenuBox.tsx b/client/src/components/atoms/DropMenuBox/DropMenuBox.tsx new file mode 100644 index 0000000..279a556 --- /dev/null +++ b/client/src/components/atoms/DropMenuBox/DropMenuBox.tsx @@ -0,0 +1,46 @@ +import { color } from '@theme/index'; +import React from 'react'; +import { useSelector } from 'react-redux'; +import styled from 'styled-components'; + +interface DropMenuBoxProps { + children: React.ReactNode; + onClick?: () => void; +} + +const BackgroundModal = styled.div` + position: fixed; + top: 0; + left: 0; + display: flex; + width: 100vw; + height: 100vh; + background-color: none; + z-index: 998; +`; + +const InnerModal = styled.div` + position: absolute; + top: ${(props) => `${props.y}px`}; + left: ${(props) => `${props.x}px`}; + padding: 1rem 0rem; + background-color: ${color.modal_bg_inner_secondary}; + z-index: 999; + border-radius: 6px; + box-shadow: 0 0 0 1px rgb(248 248 248), 0 4px 12px 0 rgba(0, 0, 0, 0.12); + height: fit-content; +`; + +const DropMenuBox: React.FC = ({ children, onClick, ...props }) => { + const { x, y } = useSelector((store: any) => store.modal.channelModal); + return ( + <> + + + {children} + + + ); +}; + +export { DropMenuBox, DropMenuBoxProps }; diff --git a/client/src/components/atoms/DropMenuItem/DropMenuItem.stories.tsx b/client/src/components/atoms/DropMenuItem/DropMenuItem.stories.tsx new file mode 100644 index 0000000..7234249 --- /dev/null +++ b/client/src/components/atoms/DropMenuItem/DropMenuItem.stories.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { Story, Meta } from '@storybook/react/types-6-0'; +import { DropMenuItem, DropMenuItemProps } from './DropMenuItem'; + +export default { + title: 'atom/DropMenuItem', + component: DropMenuItem +} as Meta; + +const Template: Story = (args) => ; + +export const PrimaryDropMenuItem = Template.bind({}); +PrimaryDropMenuItem.args = { + children: 'item' +}; diff --git a/client/src/components/atoms/DropMenuItem/DropMenuItem.tsx b/client/src/components/atoms/DropMenuItem/DropMenuItem.tsx new file mode 100644 index 0000000..9c570da --- /dev/null +++ b/client/src/components/atoms/DropMenuItem/DropMenuItem.tsx @@ -0,0 +1,28 @@ +import { color } from '@theme/index'; +import React from 'react'; +import styled from 'styled-components'; + +interface DropMenuItemProps { + children: React.ReactNode; + onClick?: () => void; +} + +const StyledDropMenuItem = styled.div` + width: 100%; + font-weight: 500; + padding: 0.2rem 0rem; + padding-left: 1.5rem; + width: 13rem; + color: ${color.text_tertiary}; + cursor: pointer; + &:hover { + color: ${color.text_secondary}; + background-color: ${color.hover_secondary}; + } +`; + +const DropMenuItem: React.FC = ({ children, onClick }) => { + return {children}; +}; + +export { DropMenuItem, DropMenuItemProps }; diff --git a/client/src/components/atoms/Emoji/Emoji.stories.tsx b/client/src/components/atoms/Emoji/Emoji.stories.tsx new file mode 100644 index 0000000..a8be082 --- /dev/null +++ b/client/src/components/atoms/Emoji/Emoji.stories.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Story, Meta } from '@storybook/react/types-6-0'; +import { Emoji, EmojiProps } from './Emoji'; + +export default { + title: 'atom/Emoji', + component: Emoji +} as Meta; + +const Template: Story = (args) => ; + +export const GoodEmoji = Template.bind({}); +GoodEmoji.args = { + text: ':+1:' +}; + +export const HeartEmoji = Template.bind({}); +HeartEmoji.args = { + text: ':heart:' +}; + +export const CheckEmoji = Template.bind({}); +CheckEmoji.args = { + text: ':white_check_mark:' +}; diff --git a/client/src/components/atoms/Emoji/Emoji.tsx b/client/src/components/atoms/Emoji/Emoji.tsx new file mode 100644 index 0000000..a8d8f65 --- /dev/null +++ b/client/src/components/atoms/Emoji/Emoji.tsx @@ -0,0 +1,7 @@ +import Emoji from 'react-emoji-render'; + +interface EmojiProps { + text: string; +} + +export { Emoji, EmojiProps }; diff --git a/client/src/components/atoms/HoverInput/HoverInput.tsx b/client/src/components/atoms/HoverInput/HoverInput.tsx index d5ad536..c0b0bc6 100644 --- a/client/src/components/atoms/HoverInput/HoverInput.tsx +++ b/client/src/components/atoms/HoverInput/HoverInput.tsx @@ -4,6 +4,7 @@ import { color } from '@theme/index'; interface HoverInputProps { placeholder?: string; + onChange?: (e: any) => void; } const StyledHoverInput = styled.input` @@ -21,8 +22,8 @@ const StyledHoverInput = styled.input` } `; -const HoverInput: React.FC = ({ placeholder, ...props }) => { - return ; +const HoverInput: React.FC = ({ placeholder, onChange, ...props }) => { + return ; }; export { HoverInput, HoverInputProps }; diff --git a/client/src/components/atoms/Icon/Icon.stories.tsx b/client/src/components/atoms/Icon/Icon.stories.tsx index 63adceb..a64655d 100644 --- a/client/src/components/atoms/Icon/Icon.stories.tsx +++ b/client/src/components/atoms/Icon/Icon.stories.tsx @@ -5,6 +5,14 @@ import Channel from '@imgs/channel-icon.png'; import Lock from '@imgs/lock-icon.png'; import Star from '@imgs/star.png'; import BlueStar from '@imgs/star-blue.png'; +import Plus from '@imgs/plus-icon.png'; +import Detail from '@imgs/detail-icon.png'; +import User from '@imgs/user-icon.png'; +import Option from '@imgs/option-icon.png'; +import Sort from '@imgs/sort-icon.png'; +import Filter from '@imgs/filter-icon.png'; +import Search from '@imgs/search-icon.png'; +import CloseFilled from '@imgs/close-filled-icon.png'; import { Icon, IconProps } from './Icon'; export default { @@ -43,3 +51,51 @@ BlueStarIcon.args = { size: 'medium', src: BlueStar }; + +export const PlusIcon = Template.bind({}); +PlusIcon.args = { + size: 'medium', + src: Plus +}; + +export const DetailIcon = Template.bind({}); +DetailIcon.args = { + size: 'medium', + src: Detail +}; + +export const UserIcon = Template.bind({}); +UserIcon.args = { + size: 'medium', + src: User +}; + +export const OptionIcon = Template.bind({}); +OptionIcon.args = { + size: 'medium', + src: Option +}; + +export const SortIcon = Template.bind({}); +SortIcon.args = { + size: 'medium', + src: Sort +}; + +export const FilterIcon = Template.bind({}); +FilterIcon.args = { + size: 'medium', + src: Filter +}; + +export const SearchIcon = Template.bind({}); +SearchIcon.args = { + size: 'medium', + src: Search +}; + +export const CloseFilledIcon = Template.bind({}); +CloseFilledIcon.args = { + size: 'medium', + src: CloseFilled +}; diff --git a/client/src/components/atoms/Input/Input.tsx b/client/src/components/atoms/Input/Input.tsx index 073a971..b44b416 100644 --- a/client/src/components/atoms/Input/Input.tsx +++ b/client/src/components/atoms/Input/Input.tsx @@ -1,9 +1,13 @@ import React from 'react'; import styled from 'styled-components'; +import { KeyCode } from '@constants/index'; interface InputProps { title?: string; isThread?: boolean; + content: string; + setContent: any; + keyPressEnter: any; } const StyledInput = styled.input` @@ -13,8 +17,22 @@ const StyledInput = styled.input` outline: none; `; -const Input: React.FC = ({ title, isThread = false, ...props }) => { - return ; +const Input: React.FC = ({ title, isThread = false, content, setContent, keyPressEnter, ...props }) => { + const handlingKeyPressEnter = (e: any) => { + if (e.charCode === KeyCode.ENTER) keyPressEnter(e.target.value); + }; + const handlingChange = (e: any) => { + setContent(e.target.value); + }; + return ( + + ); }; export { Input, InputProps }; diff --git a/client/src/components/atoms/ModalBox/ModalBox.tsx b/client/src/components/atoms/ModalBox/ModalBox.tsx index 72237d7..9fe9dfd 100644 --- a/client/src/components/atoms/ModalBox/ModalBox.tsx +++ b/client/src/components/atoms/ModalBox/ModalBox.tsx @@ -1,14 +1,16 @@ import { color } from '@theme/index'; -import React from 'react'; +import React, { useRef } from 'react'; import styled from 'styled-components'; interface ModalBoxProps { children: React.ReactNode; - onClick?: () => void; + onClick: () => void; } const BackgroundModal = styled.div` position: fixed; + top: 0; + left: 0; display: flex; justify-content: center; align-items: center; @@ -26,8 +28,16 @@ const InnerModal = styled.div` `; const ModalBox: React.FC = ({ children, onClick, ...props }) => { + const BackgroundModalRef = useRef(); + + const handlingBackgroundModalClick = (e: any) => { + if (e.target === BackgroundModalRef.current) { + onClick(); + } + }; + return ( - + {children} ); diff --git a/client/src/components/atoms/Text/Text.tsx b/client/src/components/atoms/Text/Text.tsx index 12d8d4f..ea434c0 100644 --- a/client/src/components/atoms/Text/Text.tsx +++ b/client/src/components/atoms/Text/Text.tsx @@ -3,12 +3,13 @@ import React from 'react'; import styled from 'styled-components'; interface TextProps { - size?: 'small' | 'medium' | 'large' | 'big'; + size?: 'superSmall' | 'small' | 'medium' | 'large' | 'big'; children: React.ReactChild; isBold?: boolean; fontColor?: string; isSelect?: boolean; isTitle?: boolean; + width?: string; } const StyledText = styled.p` @@ -17,10 +18,12 @@ const StyledText = styled.p` if (props.size === 'big') return '3rem'; if (props.size === 'large') return '1.5rem'; if (props.size === 'medium') return '1.3rem'; - return '1.0rem'; + if (props.size === 'small') return '1.0rem'; + return '0.8rem'; }}; font-weight: ${(props) => (props.isBold ? 'bold' : 'none')}; margin: 0; + width: ${(props) => props.width}; `; const Text: React.FC = ({ @@ -30,10 +33,11 @@ const Text: React.FC = ({ isTitle = false, isBold = false, isSelect = false, + width = 'auto', ...props }) => { return ( - + {children} ); diff --git a/client/src/components/atoms/index.ts b/client/src/components/atoms/index.ts index d714ce9..3566e4b 100644 --- a/client/src/components/atoms/index.ts +++ b/client/src/components/atoms/index.ts @@ -7,5 +7,8 @@ import { Button } from './Button/Button'; import { LogoImg } from './LogoImg/LogoImg'; import { ModalBox } from './ModalBox/ModalBox'; import { HoverInput } from './HoverInput/HoverInput'; +import { Emoji } from './Emoji/Emoji'; +import { DropMenuBox } from './DropMenuBox/DropMenuBox'; +import { DropMenuItem } from './DropMenuItem/DropMenuItem'; -export { ActiveLight, Icon, ProfileImg, Text, Input, Button, LogoImg, ModalBox, HoverInput }; +export { ActiveLight, Icon, ProfileImg, Text, Input, Button, LogoImg, ModalBox, HoverInput, Emoji, DropMenuBox, DropMenuItem }; diff --git a/client/src/components/molecules/Actionbar/Actionbar.stories.tsx b/client/src/components/molecules/Actionbar/Actionbar.stories.tsx new file mode 100644 index 0000000..5d173db --- /dev/null +++ b/client/src/components/molecules/Actionbar/Actionbar.stories.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { Story, Meta } from '@storybook/react/types-6-0'; +import { Actionbar, ActionbarProps } from './Actionbar'; + +export default { + title: 'molecules/Actionbar', + component: Actionbar +} as Meta; + +const Template: Story = (args) => ; + +export const BlackActionbar = Template.bind({}); +BlackActionbar.args = { + src: 'https://avatars2.githubusercontent.com/u/33643752?s=460&u=a9a75e7c6922a23eb365b258a60499bbb9a9c655&v=4', + author: 'J030_김도호', + content: + '안녕하세요. 김도호입니다. 안녕하세요. 김도호입니다. 안녕하세요. 김도호입니다. 안녕하세요. 김도호입니다. 안녕하세요. 김도호입니다. 안녕하세요. 김도호입니다. 안녕하세요. 김도호입니다. 안녕하세요. 김도호입니다. 안녕하세요. 김도호입니다. ' +}; diff --git a/client/src/components/molecules/Actionbar/Actionbar.tsx b/client/src/components/molecules/Actionbar/Actionbar.tsx new file mode 100644 index 0000000..164f59c --- /dev/null +++ b/client/src/components/molecules/Actionbar/Actionbar.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import styled from 'styled-components'; +import { HoverIcon } from '@components/molecules'; +import { color } from '@theme/index'; +import EmojiIcon from '@imgs/emoji-icon.png'; +import ThreadIcon from '@imgs/thread-icon.png'; +import OptionIcon from '@imgs/option-icon.png'; + +interface ActionbarProps {} + +const ActionbarContainer = styled.div` + display: flex; + position: absolute; + top: -1rem; + right: 0.3rem; + background-color: ${color.tertiary}; + border: 1.5px solid ${color.border_primary}; + border-radius: 0.2rem; +`; + +const Actionbar: React.FC = ({ ...props }) => { + return ( + + + + + + ); +}; + +export { Actionbar, ActionbarProps }; diff --git a/client/src/components/molecules/AddChannelButton/AddChannelButton.stories.tsx b/client/src/components/molecules/AddChannelButton/AddChannelButton.stories.tsx new file mode 100644 index 0000000..b8f1691 --- /dev/null +++ b/client/src/components/molecules/AddChannelButton/AddChannelButton.stories.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { Story, Meta } from '@storybook/react/types-6-0'; +import { AddChannelButton, AddChannelButtonProps } from './AddChannelButton'; + +export default { + title: 'molecules/AddChannelButton', + component: AddChannelButton +} as Meta; + +const Template: Story = (args) => ; + +export const BlackAddChannelButton = Template.bind({}); +BlackAddChannelButton.args = {}; diff --git a/client/src/components/molecules/AddChannelButton/AddChannelButton.tsx b/client/src/components/molecules/AddChannelButton/AddChannelButton.tsx new file mode 100644 index 0000000..0f289e4 --- /dev/null +++ b/client/src/components/molecules/AddChannelButton/AddChannelButton.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import styled from 'styled-components'; +import Plus from '@imgs/plus-icon.png'; +import { Icon } from '@components/atoms'; +import { useDispatch } from 'react-redux'; +import { channelModalOpen } from '@store/actions/modal-action'; + +interface AddChannelButtonProps { + sectionName: string; + setHover: React.Dispatch>; +} + +const IconWrap = styled.div` + display: flex; + cursor: pointer; +`; + +const AddChannelButton: React.FC = ({ setHover, sectionName, ...props }) => { + const dispatch = useDispatch(); + const handlingHoverIconClick = (e: any) => { + const x = window.pageXOffset + e.target.getBoundingClientRect().left; + const y = window.pageYOffset + e.target.getBoundingClientRect().top; + dispatch(channelModalOpen({ x, y })); + }; + + return ( + <> + + + + + ); +}; + +export { AddChannelButton, AddChannelButtonProps }; diff --git a/client/src/components/molecules/BrowsePageChannelBody/BrowsePageChannelBody.stories.tsx b/client/src/components/molecules/BrowsePageChannelBody/BrowsePageChannelBody.stories.tsx new file mode 100644 index 0000000..a954773 --- /dev/null +++ b/client/src/components/molecules/BrowsePageChannelBody/BrowsePageChannelBody.stories.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { Story, Meta } from '@storybook/react/types-6-0'; +import { BrowsePageChannelBody, BrowsePageChannelBodyProps } from './BrowsePageChannelBody'; + +export default { + title: 'molecules/BrowsePageChannelBody', + component: BrowsePageChannelBody +} as Meta; + +const Template: Story = (args) => ; + +export const BlackBrowsePageChannelBody = Template.bind({}); +BlackBrowsePageChannelBody.args = { + isJoined: true, + memberCount: 4, + description: '공지사항을 안내하는 채널' +}; diff --git a/client/src/components/molecules/BrowsePageChannelBody/BrowsePageChannelBody.tsx b/client/src/components/molecules/BrowsePageChannelBody/BrowsePageChannelBody.tsx new file mode 100644 index 0000000..a8f014f --- /dev/null +++ b/client/src/components/molecules/BrowsePageChannelBody/BrowsePageChannelBody.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import styled from 'styled-components'; +import { Text } from '@components/atoms'; +import { color } from '@theme/index'; + +interface BrowsePageChannelBodyProps { + isJoined?: boolean; + memberCount: number; + description?: string; +} + +const BrowsePageChannelBodyWrap = styled.div` + display: flex; + p { + margin-right: 0.3rem; + } +`; + +const BrowsePageChannelBody: React.FC = ({ isJoined, memberCount, description, ...props }) => { + return ( + + {isJoined && ( + + {`✓ Joined ·`} + + )} + + {`${memberCount} members`} + + {description && ( + + {`· ${description}`} + + )} + + ); +}; + +export { BrowsePageChannelBody, BrowsePageChannelBodyProps }; diff --git a/client/src/components/molecules/BrowsePageChannelButton/BrowsePageChannelButton.stories.tsx b/client/src/components/molecules/BrowsePageChannelButton/BrowsePageChannelButton.stories.tsx new file mode 100644 index 0000000..8ad79ae --- /dev/null +++ b/client/src/components/molecules/BrowsePageChannelButton/BrowsePageChannelButton.stories.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { Story, Meta } from '@storybook/react/types-6-0'; +import { BrowsePageChannelButton, BrowsePageChannelButtonProps } from './BrowsePageChannelButton'; + +export default { + title: 'molecules/BrowsePageChannelButton', + component: BrowsePageChannelButton +} as Meta; + +const Template: Story = (args) => ; + +export const BlackBrowsePageChannelButton = Template.bind({}); +BlackBrowsePageChannelButton.args = { + isJoined: true +}; diff --git a/client/src/components/molecules/BrowsePageChannelButton/BrowsePageChannelButton.tsx b/client/src/components/molecules/BrowsePageChannelButton/BrowsePageChannelButton.tsx new file mode 100644 index 0000000..f0089ee --- /dev/null +++ b/client/src/components/molecules/BrowsePageChannelButton/BrowsePageChannelButton.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Button } from '@components/atoms'; +import { color } from '@theme/index'; + +interface BrowsePageChannelButtonProps { + isJoined?: boolean; + handlingJoinButton?: () => void; + handlingLeaveButton?: () => void; +} + +const BrowsePageChannelButton: React.FC = ({ isJoined, handlingJoinButton, handlingLeaveButton, ...props }) => { + return ( + <> + {isJoined ? ( + + ) : ( + + )} + + ); +}; + +export { BrowsePageChannelButton, BrowsePageChannelButtonProps }; diff --git a/client/src/components/molecules/BrowsePageChannelHeader/BrowsePageChannelHeader.stories.tsx b/client/src/components/molecules/BrowsePageChannelHeader/BrowsePageChannelHeader.stories.tsx new file mode 100644 index 0000000..e4aa9df --- /dev/null +++ b/client/src/components/molecules/BrowsePageChannelHeader/BrowsePageChannelHeader.stories.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { Story, Meta } from '@storybook/react/types-6-0'; +import { BrowsePageChannelHeader, BrowsePageChannelHeaderProps } from './BrowsePageChannelHeader'; + +export default { + title: 'molecules/BrowsePageChannelHeader', + component: BrowsePageChannelHeader +} as Meta; + +const Template: Story = (args) => ; + +export const BlackBrowsePageChannelHeader = Template.bind({}); +BlackBrowsePageChannelHeader.args = { + name: 'notice', + isPrivate: true +}; diff --git a/client/src/components/molecules/BrowsePageChannelHeader/BrowsePageChannelHeader.tsx b/client/src/components/molecules/BrowsePageChannelHeader/BrowsePageChannelHeader.tsx new file mode 100644 index 0000000..24a8c95 --- /dev/null +++ b/client/src/components/molecules/BrowsePageChannelHeader/BrowsePageChannelHeader.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import styled from 'styled-components'; +import { Icon, Text } from '@components/atoms'; +import ChannelIcon from '@imgs/channel-icon.png'; +import LockIcon from '@imgs/lock-icon.png'; +import { color } from '@theme/index'; + +interface BrowsePageChannelHeaderProps { + name: string; + isPrivate?: boolean; +} + +const BrowsePageChannelHeaderWrap = styled.div` + display: flex; + p { + margin-left: 0.3rem; + } +`; + +const BrowsePageChannelHeader: React.FC = ({ name, isPrivate, ...props }) => { + return ( + + + + {name} + + + ); +}; + +export { BrowsePageChannelHeader, BrowsePageChannelHeaderProps }; diff --git a/client/src/components/molecules/BrowsePageControls/BrowsePageControls.stories.tsx b/client/src/components/molecules/BrowsePageControls/BrowsePageControls.stories.tsx new file mode 100644 index 0000000..1ec0365 --- /dev/null +++ b/client/src/components/molecules/BrowsePageControls/BrowsePageControls.stories.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { Story, Meta } from '@storybook/react/types-6-0'; +import { BrowsePageControls, BrowsePageControlsProps } from './BrowsePageControls'; + +export default { + title: 'molecules/BrowsePageControls', + component: BrowsePageControls +} as Meta; + +const handlingSortButton = () => {}; +const handlingFilterButton = () => {}; + +const Template: Story = (args) => ; + +export const BlackBrowsePageControls = Template.bind({}); +BlackBrowsePageControls.args = { + channelCount: 193, + handlingSortButton, + handlingFilterButton +}; diff --git a/client/src/components/molecules/BrowsePageControls/BrowsePageControls.tsx b/client/src/components/molecules/BrowsePageControls/BrowsePageControls.tsx new file mode 100644 index 0000000..062f347 --- /dev/null +++ b/client/src/components/molecules/BrowsePageControls/BrowsePageControls.tsx @@ -0,0 +1,56 @@ +import React, { useState } from 'react'; +import styled from 'styled-components'; +import { Text } from '@components/atoms'; +import { color } from '@theme/index'; +import { WhiteButtonWithIcon } from '@components/molecules'; +import SortIcon from '@imgs/sort-icon.png'; +import FilterIcon from '@imgs/filter-icon.png'; + +interface BrowsePageControlsProps { + channelCount: number; + handlingSortButton: () => void; + handlingFilterButton: () => void; +} + +export const SortMethods = { + NEWEST_CHANNEL: 'Newest channel', + OLDEST_CHANNEL: 'Oldest channel', + MOST_MEMBERS: 'Most members', + A_TO_Z: 'A to Z', + Z_TO_A: 'Z to A' +}; + +type SortMethod = typeof SortMethods[keyof typeof SortMethods]; + +const BrowsePageControlsWrap = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1rem; +`; + +const BrowsePageControlsButtonWrap = styled.div` + display: flex; +`; + +const BrowsePageControls: React.FC = ({ channelCount, handlingSortButton, handlingFilterButton, ...props }) => { + const [sortMethod, setSortMethod] = useState(SortMethods.A_TO_Z); + + return ( + + + {`${channelCount} channels`} + + + + {`Sort: ${sortMethod}`} + + + Filter + + + + ); +}; + +export { BrowsePageControls, BrowsePageControlsProps }; diff --git a/client/src/components/molecules/BrowsePageSearchBar/BrowsePageSearchBar.stories.tsx b/client/src/components/molecules/BrowsePageSearchBar/BrowsePageSearchBar.stories.tsx new file mode 100644 index 0000000..b9dc277 --- /dev/null +++ b/client/src/components/molecules/BrowsePageSearchBar/BrowsePageSearchBar.stories.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { Story, Meta } from '@storybook/react/types-6-0'; +import { BrowsePageSearchBar, BrowsePageSearchBarProps } from './BrowsePageSearchBar'; + +export default { + title: 'molecules/BrowsePageSearchBar', + component: BrowsePageSearchBar +} as Meta; + +const Template: Story = (args) => ; + +export const BlackBrowsePageSearchBar = Template.bind({}); +BlackBrowsePageSearchBar.args = {}; diff --git a/client/src/components/molecules/BrowsePageSearchBar/BrowsePageSearchBar.tsx b/client/src/components/molecules/BrowsePageSearchBar/BrowsePageSearchBar.tsx new file mode 100644 index 0000000..858f16b --- /dev/null +++ b/client/src/components/molecules/BrowsePageSearchBar/BrowsePageSearchBar.tsx @@ -0,0 +1,86 @@ +import React, { useState, useRef } from 'react'; +import styled from 'styled-components'; +import SearchIcon from '@imgs/search-icon.png'; +import CloseFilledIcon from '@imgs/close-filled-icon.png'; +import { Icon, Text } from '@components/atoms'; +import { color } from '@theme/index'; +import { KeyCode } from '@constants/index'; + +interface BrowsePageSearchBarProps {} + +const BrowsePageSearchBarWrap = styled.div` + display: flex; + align-items: center; + border: 1px solid ${color.secondary}; + border-radius: 0.2rem; + padding: 0.5rem 0.6rem; +`; + +const StyledInput = styled.input` + border: 0 none; + outline: none; + width: fill-available; + font-size: 1rem; +`; + +const InputHintWrap = styled.div` + display: flex; + align-items: center; +`; + +const SearchIconWrap = styled.div` + margin-right: 0.5rem; +`; + +const CloseFilledIconWrap = styled.div` + cursor: pointer; + margin-left: 0.5rem; +`; + +const BrowsePageSearchBar: React.FC = ({ ...props }) => { + const [searchWord, setSearchWord] = useState(''); + const searchInput: React.MutableRefObject = useRef(); + + const onChangeSearchWord = (e: any) => { + setSearchWord(e.target.value); + }; + + const clearInputValue = () => { + searchInput.current.value = ''; + setSearchWord(''); + }; + + const handlingKeyPressEnter = (e: any) => { + if (e.charCode === KeyCode.ENTER) { + clearInputValue(); + } + }; + + return ( + + + + + + {searchWord && ( + <> + + + Press enter to search + + + + + + + )} + + ); +}; + +export { BrowsePageSearchBar, BrowsePageSearchBarProps }; diff --git a/client/src/components/molecules/Channel/Channel.tsx b/client/src/components/molecules/Channel/Channel.tsx index 290f100..297885d 100644 --- a/client/src/components/molecules/Channel/Channel.tsx +++ b/client/src/components/molecules/Channel/Channel.tsx @@ -5,13 +5,14 @@ import ChannelIcon from '@imgs/channel-icon.png'; import LockIcon from '@imgs/lock-icon.png'; import { useHistory } from 'react-router-dom'; import { color } from '@theme/index'; +import { useDispatch } from 'react-redux'; +import { pickChannel } from '@store/actions/chatroom-action'; interface ChannelProps { children: React.ReactChild; isPrivate?: boolean; isSelect?: boolean; chatroomId?: number; - chatroomClick: any; } const ChannelContainter = styled.div` @@ -26,11 +27,13 @@ const TextWrap = styled.div` margin-left: 1rem; `; -const Channel: React.FC = ({ children, chatroomClick, chatroomId, isPrivate = false, isSelect = false, ...props }) => { +const Channel: React.FC = ({ children, chatroomId, isPrivate = false, isSelect = false, ...props }) => { const history = useHistory(); + const dispatch = useDispatch(); + const handlingClick = () => { if (window.location.pathname !== `/client/${chatroomId}`) history.push(`/client/${chatroomId}`); - chatroomClick(chatroomId); + dispatch(pickChannel({ selectedChatroomId: chatroomId })); }; return ( diff --git a/client/src/components/molecules/Channel/ChannelContainer.tsx b/client/src/components/molecules/Channel/ChannelContainer.tsx deleted file mode 100644 index 64e5a6d..0000000 --- a/client/src/components/molecules/Channel/ChannelContainer.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { connect } from 'react-redux'; -import { getChatroomInfo } from '@dispatch/index'; -import { Channel } from './Channel'; - -function mapDispatchToProps(dispatch: any) { - return { - chatroomClick(selectedChatroomId: number) { - dispatch({ type: 'UPDATESIDEBAR', selectedChatroomId }); - getChatroomInfo(selectedChatroomId); - } - }; -} - -export default connect(null, mapDispatchToProps)(Channel); diff --git a/client/src/components/molecules/ChannelModal/ChannelModal.stories.tsx b/client/src/components/molecules/ChannelModal/ChannelModal.stories.tsx new file mode 100644 index 0000000..84652b9 --- /dev/null +++ b/client/src/components/molecules/ChannelModal/ChannelModal.stories.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { Story, Meta } from '@storybook/react/types-6-0'; +import { ChannelModal, ChannelModalProps } from './ChannelModal'; + +export default { + title: 'molecules/ChannelModal', + component: ChannelModal +} as Meta; + +const Template: Story = (args) => ; + +export const BlackChannelModal = Template.bind({}); +BlackChannelModal.args = {}; diff --git a/client/src/components/molecules/ChannelModal/ChannelModal.tsx b/client/src/components/molecules/ChannelModal/ChannelModal.tsx new file mode 100644 index 0000000..4320dc6 --- /dev/null +++ b/client/src/components/molecules/ChannelModal/ChannelModal.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { DropMenuBox, DropMenuItem } from '@components/atoms'; +import { useDispatch, useSelector } from 'react-redux'; +import { createModalOpen, channelModalClose } from '@store/actions/modal-action'; + +interface ChannelModalProps {} + +const ChannelModal: React.FC = ({ ...props }) => { + const dispatch = useDispatch(); + const isOpen = useSelector((store: any) => store.modal.channelModal.isOpen); + const handlingCloseModal = () => { + dispatch(channelModalClose()); + }; + const handlingBrowseChannelsClick = () => { + dispatch(channelModalClose()); + }; + const handlingCreateChannelClick = () => { + dispatch(createModalOpen()); + }; + return ( + <> + {isOpen ? ( + handlingCloseModal()} {...props}> + Browse channels + Create a channel + + ) : null} + + ); +}; + +export { ChannelModal, ChannelModalProps }; diff --git a/client/src/components/molecules/DM/DM.tsx b/client/src/components/molecules/DM/DM.tsx index 2b2f085..2f3b22b 100644 --- a/client/src/components/molecules/DM/DM.tsx +++ b/client/src/components/molecules/DM/DM.tsx @@ -4,13 +4,14 @@ import { Text } from '@components/atoms'; import { ActiveProfileImg } from '@components/molecules'; import { useHistory } from 'react-router-dom'; import { color } from '@theme/index'; +import { useDispatch } from 'react-redux'; +import { pickChannel } from '@store/actions/chatroom-action'; interface DMProps { children: React.ReactChild; isSelect?: boolean; src?: string; chatroomId?: number; - chatroomClick: any; } const DMContainter = styled.div` @@ -25,11 +26,13 @@ const TextWrap = styled.div` margin-left: 1rem; `; -const DM: React.FC = ({ children, chatroomClick, src, chatroomId, isSelect = false, ...props }) => { +const DM: React.FC = ({ children, src, chatroomId, isSelect = false, ...props }) => { const history = useHistory(); + const dispatch = useDispatch(); + const handlingClick = () => { if (window.location.pathname !== `/client/${chatroomId}`) history.push(`/client/${chatroomId}`); - chatroomClick(chatroomId); + dispatch(pickChannel({ selectedChatroomId: chatroomId })); }; return ( diff --git a/client/src/components/molecules/DM/DMContainer.tsx b/client/src/components/molecules/DM/DMContainer.tsx deleted file mode 100644 index 3223527..0000000 --- a/client/src/components/molecules/DM/DMContainer.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { connect } from 'react-redux'; -import { getChatroomInfo } from '@dispatch/index'; -import { DM } from './DM'; - -function mapDispatchToProps(dispatch: any) { - return { - chatroomClick(selectedChatroomId: number) { - dispatch({ type: 'UPDATESIDEBAR', selectedChatroomId }); - getChatroomInfo(selectedChatroomId); - } - }; -} - -export default connect(null, mapDispatchToProps)(DM); diff --git a/client/src/components/molecules/EmojiBox/EmojiBox.stories.tsx b/client/src/components/molecules/EmojiBox/EmojiBox.stories.tsx new file mode 100644 index 0000000..c9c6ebb --- /dev/null +++ b/client/src/components/molecules/EmojiBox/EmojiBox.stories.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { Story, Meta } from '@storybook/react/types-6-0'; +import { EmojiBox, EmojiBoxProps } from './EmojiBox'; + +export default { + title: 'molecules/EmojiBox', + component: EmojiBox +} as Meta; + +const Template: Story = (args) => ; + +export const BlackEmojiBox = Template.bind({}); +BlackEmojiBox.args = { + text: ':white_check_mark:', + number: 1 +}; diff --git a/client/src/components/molecules/EmojiBox/EmojiBox.tsx b/client/src/components/molecules/EmojiBox/EmojiBox.tsx new file mode 100644 index 0000000..f1f2f60 --- /dev/null +++ b/client/src/components/molecules/EmojiBox/EmojiBox.tsx @@ -0,0 +1,51 @@ +import React, { useState } from 'react'; +import styled from 'styled-components'; +import { Emoji } from '@components/atoms'; +import { color } from '@theme/index'; + +interface EmojiBoxProps { + text: string; + active: boolean; + number: number; +} + +const EmojiBoxContainer = styled.div` + display: flex; + justify-content: space-evenly; + align-items: center; + padding-bottom: 0.2rem; + width: 3rem; + background-color: ${(props) => (props.isActive ? color.emoji_bg : color.quaternary)}; + border-radius: 1rem; + cursor: pointer; + box-shadow: 0 0 0 1px inset ${(props) => (props.isActive ? color.border_tertiary : color.quaternary)}; + + ${(props) => + props.isActive + ? null + : `&:hover { + background-color: ${color.tertiary}; + box-shadow: 0 0 0 1px inset;`}} +`; + +const EmojiBoxText = styled.p` + font-size: 0.9rem; + margin: 0; +`; + +const EmojiBox: React.FC = ({ active = false, number, text, ...props }) => { + const [isActive, setActive] = useState(active); + + const handlingClick = () => { + setActive(!isActive); + }; + + return ( + + + {number} + + ); +}; + +export { EmojiBox, EmojiBoxProps }; diff --git a/client/src/components/molecules/GithubLoginButton/GithubLoginButton.tsx b/client/src/components/molecules/GithubLoginButton/GithubLoginButton.tsx index b0c9c5f..8415c4b 100644 --- a/client/src/components/molecules/GithubLoginButton/GithubLoginButton.tsx +++ b/client/src/components/molecules/GithubLoginButton/GithubLoginButton.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Button, Icon, Text } from '@components/atoms'; import styled from 'styled-components'; -import { api } from '@utils/index'; +import { API } from '@utils/index'; import { color } from '@theme/index'; interface GithubLoginButtonProps { @@ -13,7 +13,7 @@ const TextWrap = styled.div` `; const handlingGithubLoginButton = async () => { - await api.oauthLogin(); + await API.oauthLogin(); }; const GithubLoginButton: React.FC = ({ ...props }) => { diff --git a/client/src/components/molecules/HoverIcon/HoverIcon.stories.tsx b/client/src/components/molecules/HoverIcon/HoverIcon.stories.tsx new file mode 100644 index 0000000..bb0dfca --- /dev/null +++ b/client/src/components/molecules/HoverIcon/HoverIcon.stories.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { Story, Meta } from '@storybook/react/types-6-0'; +import Plus from '@imgs/plus-icon.png'; +import { HoverIcon, HoverIconProps } from './HoverIcon'; + +export default { + title: 'molecules/HoverIcon', + component: HoverIcon +} as Meta; + +const Template: Story = (args) => ; + +export const MediumHoverIcon = Template.bind({}); +MediumHoverIcon.args = { + src: Plus, + size: 'medium' +}; + +export const LargeHoverIcon = Template.bind({}); +LargeHoverIcon.args = { + src: Plus, + size: 'large' +}; diff --git a/client/src/components/molecules/HoverIcon/HoverIcon.tsx b/client/src/components/molecules/HoverIcon/HoverIcon.tsx new file mode 100644 index 0000000..65f28a4 --- /dev/null +++ b/client/src/components/molecules/HoverIcon/HoverIcon.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import styled from 'styled-components'; +import { Icon } from '@components/atoms'; +import { color } from '@theme/index'; + +interface HoverIconProps { + size: 'small' | 'medium' | 'large'; + src?: string; +} + +const StyledHoverIcon = styled.div` + display: flex; + justify-content: center; + align-items: center; + width: ${(props) => { + if (props.size === 'large') return '2.4rem'; + if (props.size === 'medium') return '2.0rem'; + return '1.8rem'; + }}; + height: ${(props) => { + if (props.size === 'large') return '2.4rem'; + if (props.size === 'medium') return '2.0rem'; + return '1.8rem'; + }}; + border-radius: 0.4rem; + &:hover { + background-color: ${color.hover_primary}; + } +`; + +const HoverIcon: React.FC = ({ size = 'medium', src, ...props }) => { + return ( + + + + ); +}; + +export { HoverIcon, HoverIconProps }; diff --git a/client/src/components/molecules/InputMessage/InputMessage.tsx b/client/src/components/molecules/InputMessage/InputMessage.tsx index d10759b..5a214f3 100644 --- a/client/src/components/molecules/InputMessage/InputMessage.tsx +++ b/client/src/components/molecules/InputMessage/InputMessage.tsx @@ -1,12 +1,16 @@ -import React from 'react'; +import React, { useState } from 'react'; import styled from 'styled-components'; import { Input } from '@components/atoms'; import { SendMessageButton } from '@components/molecules'; import { color } from '@theme/index'; +import { createMessage } from '@socket/emits/message'; +import { ChatroomEventType } from '@constants/index'; interface InputMessageProps { isThread?: boolean; title: string; + setEventType: any; + chatRoomId: number | null; } const InputMessageContainer = styled.div` @@ -29,14 +33,22 @@ const ButtonWrap = styled.div` margin-right: 1rem; `; -const InputMessage: React.FC = ({ children, title, isThread, ...props }) => { +const InputMessage: React.FC = ({ children, title, isThread, chatRoomId, setEventType, ...props }) => { + const [content, setContent] = useState(''); + + const sendMessage = () => { + setEventType(ChatroomEventType.INPUTTEXT); + createMessage({ content, chatroomId: chatRoomId }); + setContent(''); + }; + return ( - + - + ); diff --git a/client/src/components/molecules/Message/Message.tsx b/client/src/components/molecules/Message/Message.tsx index 7c0e5aa..e491c67 100644 --- a/client/src/components/molecules/Message/Message.tsx +++ b/client/src/components/molecules/Message/Message.tsx @@ -1,16 +1,20 @@ -import React from 'react'; +import React, { useRef, useState } from 'react'; import styled from 'styled-components'; import { ProfileImg, Text } from '@components/atoms'; +import { Actionbar } from '@components/molecules'; import { color } from '@theme/index'; +import { getTimeConversionValue } from '@utils/time'; interface MessageProps { src: string; author: string; content: string; + createdAt: Date; } const MessageContainer = styled.div` display: flex; + position: relative; padding: 1rem 1rem; &:hover { background-color: ${color.hover_primary}; @@ -18,22 +22,41 @@ const MessageContainer = styled.div` `; const ProfileImgWrap = styled.div` - width: 5rem; + width: 3rem; `; const MessageContent = styled.div` display: flex; flex-direction: column; - margin-left: 0.5rem; + margin-left: 0.2rem; `; const MessageHeader = styled.div` display: flex; + align-items: center; `; -const Message: React.FC = ({ author, content, src, ...props }) => { +const DateText = styled.p` + margin: 0 0.3rem; + color: gray; + font-size: 0.7rem; +`; + +const Message: React.FC = ({ author, content, src, createdAt, ...props }) => { + const [isHover, setHover] = useState(false); + const messageContainer = useRef(); + const onMouseEnter = (e: any) => { + if (e.target === messageContainer.current) { + setHover(true); + } + }; + const onMouseLeave = (e: any) => { + if (e.target === messageContainer.current) { + setHover(false); + } + }; return ( - + @@ -42,11 +65,13 @@ const Message: React.FC = ({ author, content, src, ...props }) => {author} + {getTimeConversionValue(createdAt)} {content} + {isHover ? : null} ); }; diff --git a/client/src/components/molecules/MessageReplyBar/MessageReplyBar.stories.tsx b/client/src/components/molecules/MessageReplyBar/MessageReplyBar.stories.tsx new file mode 100644 index 0000000..2ad4352 --- /dev/null +++ b/client/src/components/molecules/MessageReplyBar/MessageReplyBar.stories.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Story, Meta } from '@storybook/react/types-6-0'; +import { MessageReplyBar, MessageReplyBarProps } from './MessageReplyBar'; + +export default { + title: 'molecules/MessageReplyBar', + component: MessageReplyBar +} as Meta; + +const Template: Story = (args) => ; + +const handlingMessageReplyBarClick = () => {}; + +export const OneMessageReplyBar = Template.bind({}); +OneMessageReplyBar.args = { + profileImgs: ['https://avatars1.githubusercontent.com/u/59037261?s=64&v=4'], + replyCount: 1, + lastRepliedTime: new Date('2020-12-07 23:48:52.547160'), + onClick: handlingMessageReplyBarClick +}; + +export const ThreeMessageReplyBar = Template.bind({}); +ThreeMessageReplyBar.args = { + profileImgs: [ + 'https://avatars3.githubusercontent.com/u/33643752?s=64&v=4', + 'https://avatars1.githubusercontent.com/u/59037261?s=64&v=4', + 'https://avatars0.githubusercontent.com/u/37091190?s=64&v=4' + ], + replyCount: 3, + lastRepliedTime: new Date('2020-12-08 08:32:51.536510'), + onClick: handlingMessageReplyBarClick +}; diff --git a/client/src/components/molecules/MessageReplyBar/MessageReplyBar.tsx b/client/src/components/molecules/MessageReplyBar/MessageReplyBar.tsx new file mode 100644 index 0000000..a6d0a9c --- /dev/null +++ b/client/src/components/molecules/MessageReplyBar/MessageReplyBar.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import styled from 'styled-components'; +import { ProfileImg, Text } from '@components/atoms'; +import { color } from '@theme/index'; +import { timeAgo } from '@utils/time'; + +interface MessageReplyBarProps { + profileImgs: Array; + replyCount: number; + lastRepliedTime: Date; + onClick?: () => void; +} + +const MessageReplyBarWrap = styled.div` + display: flex; + width: 40rem; + padding: 0.5rem; + border-radius: 0.3rem; + cursor: pointer; + &:hover { + background-color: ${color.hover_primary}; + } +`; + +const ProfileImgWrap = styled.div` + margin: 0rem 0.05rem; +`; + +const ReplyCountWrap = styled.div` + display: flex; + margin: 0rem 0.5rem; + p:hover { + text-decoration: underline; + } +`; + +const MessageReplyBar: React.FC = ({ profileImgs, replyCount, lastRepliedTime, onClick, ...props }) => { + const profileNum = profileImgs.length >= 5 ? 5 : profileImgs.length; + + const createProfileImg = profileImgs.slice(0, profileNum).map((profileImg) => ( + + + + )); + + return ( + + {createProfileImg} + + + {`${replyCount} ${replyCount === 1 ? 'reply' : 'replies'}`} + + + + {timeAgo(lastRepliedTime)} + + + ); +}; + +export { MessageReplyBar, MessageReplyBarProps }; diff --git a/client/src/components/molecules/Section/Section.stories.tsx b/client/src/components/molecules/Section/Section.stories.tsx index 09e20d3..9907c97 100644 --- a/client/src/components/molecules/Section/Section.stories.tsx +++ b/client/src/components/molecules/Section/Section.stories.tsx @@ -20,12 +20,12 @@ const Template: Story = (args) =>
; export const ChannelSection = Template.bind({}); ChannelSection.args = { - SectionName: 'Channels', + sectionName: 'Channels', children: mockChannelChildren }; export const DMSection = Template.bind({}); DMSection.args = { - SectionName: 'Direct Messages', + sectionName: 'Direct Messages', children: mockDMChildren }; diff --git a/client/src/components/molecules/Section/Section.tsx b/client/src/components/molecules/Section/Section.tsx index 940abae..dede131 100644 --- a/client/src/components/molecules/Section/Section.tsx +++ b/client/src/components/molecules/Section/Section.tsx @@ -1,12 +1,23 @@ -import React from 'react'; +import React, { useState } from 'react'; import styled from 'styled-components'; +import { AddChannelButton } from '../AddChannelButton/AddChannelButton'; interface SectionProps { children: React.ReactNode; - SectionName: string; + sectionName: string; isSelect?: boolean; } +const SectionWrap = styled.div` + position: relative; +`; + +const AddChannelButtonWrap = styled.div` + position: absolute; + top: 0; + right: 0.5rem; +`; + const StyledSection = styled.details` color: rgb(198, 199, 200); font-size: 1rem; @@ -19,12 +30,31 @@ const Summary = styled.summary` margin-bottom: 0.3rem; `; -const Section: React.FC = ({ children, SectionName = 'Section', isSelect = false, ...props }) => { +const Section: React.FC = ({ children, sectionName = 'Section', isSelect = false, ...props }) => { + const [isHover, setHover] = useState(false); + + const onMouseEnter = () => { + setHover(true); + }; + + const onMouseLeave = () => { + setHover(false); + }; + return ( - - {SectionName} - {children} - + + {isHover && ( + + + + )} + + + {sectionName} + + {children} + + ); }; diff --git a/client/src/components/molecules/SendMessageButton/SendMessageButton.tsx b/client/src/components/molecules/SendMessageButton/SendMessageButton.tsx index f5eb398..a0dd2e5 100644 --- a/client/src/components/molecules/SendMessageButton/SendMessageButton.tsx +++ b/client/src/components/molecules/SendMessageButton/SendMessageButton.tsx @@ -6,6 +6,7 @@ import { color } from '@theme/index'; interface SendMessageButtonProps { isActive: boolean; + sendMessage: () => void; } const SendMessageButtonContainer = styled.div` @@ -19,9 +20,12 @@ const SendMessageButtonContainer = styled.div` ${(props) => (props.isActive ? `background-color: ${color.button_secondary}` : '')} `; -const SendMessageButton: React.FC = ({ isActive, ...props }) => { +const SendMessageButton: React.FC = ({ isActive, sendMessage, ...props }) => { + const handlingClick = () => { + sendMessage(); + }; return ( - + ); diff --git a/client/src/components/molecules/UserBox/UserBox.stories.tsx b/client/src/components/molecules/UserBox/UserBox.stories.tsx new file mode 100644 index 0000000..60920ef --- /dev/null +++ b/client/src/components/molecules/UserBox/UserBox.stories.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Story, Meta } from '@storybook/react/types-6-0'; +import { UserBox, UserBoxProps } from './UserBox'; + +export default { + title: 'molecules/UserBox', + component: UserBox +} as Meta; + +const Template: Story = (args) => ; + +export const OneUserBox = Template.bind({}); +OneUserBox.args = { + member: [{ name: '김도호', profileUri: 'https://avatars2.githubusercontent.com/u/33643752?s=460&u=a9a75e7c6922a23eb365b258a60499bbb9a9c655&v=4' }] +}; + +export const TwoUserBox = Template.bind({}); +TwoUserBox.args = { + member: [ + { name: '강동훈', profileUri: 'https://avatars0.githubusercontent.com/u/37091190?s=400&u=d358f361db0c43c0fccdcbd31de5ded89efe0169&v=4' }, + { name: '김도호', profileUri: 'https://avatars2.githubusercontent.com/u/33643752?s=460&u=a9a75e7c6922a23eb365b258a60499bbb9a9c655&v=4' } + ] +}; + +export const ThreeUserBox = Template.bind({}); +ThreeUserBox.args = { + member: [ + { name: '강동훈', profileUri: 'https://avatars0.githubusercontent.com/u/37091190?s=400&u=d358f361db0c43c0fccdcbd31de5ded89efe0169&v=4' }, + { name: '김도호', profileUri: 'https://avatars2.githubusercontent.com/u/33643752?s=460&u=a9a75e7c6922a23eb365b258a60499bbb9a9c655&v=4' }, + { name: '탁성건', profileUri: 'https://avatars2.githubusercontent.com/u/59037261?s=460&u=7b7a0a2f151c1f49c5bc8068d4d6a5bf50c94c7b&v=4' } + ] +}; diff --git a/client/src/components/molecules/UserBox/UserBox.tsx b/client/src/components/molecules/UserBox/UserBox.tsx new file mode 100644 index 0000000..78732e6 --- /dev/null +++ b/client/src/components/molecules/UserBox/UserBox.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import styled from 'styled-components'; +import { ProfileImg } from '@components/atoms'; +import { color } from '@theme/index'; + +interface UserBoxProps { + member: Array; +} + +const UserBoxWrap = styled.div` + display: flex; + align-items: center; + &:hover { + background-color: ${color.hover_primary}; + } + cursor: pointer; +`; + +const ProfileImgWrap = styled.div` + border: 2px solid white; + border-radius: 0.5rem; + margin-left: -0.4rem; +`; + +const Text = styled.div` + margin-left: 0.7rem; + color: ${color.text_senary}; + font-weight: 600; +`; + +const UserBox: React.FC = ({ member, ...props }) => { + const memberNum = member.length; + const displayMembers: object[] = new Array(3); + const loopValue = memberNum > 3 ? 3 : memberNum; + + for (let i = 0; i < loopValue; i += 1) { + displayMembers.push({ id: member[i].userId, profileUri: member[i].profileUri }); + } + + const createProfileImg = displayMembers.map((Member: any) => ( + + + + )); + + return ( + + {createProfileImg} + {memberNum} + + ); +}; + +export { UserBox, UserBoxProps }; diff --git a/client/src/components/molecules/WhiteButtonWithIcon/WhiteButtonWithIcon.stories.tsx b/client/src/components/molecules/WhiteButtonWithIcon/WhiteButtonWithIcon.stories.tsx new file mode 100644 index 0000000..617c1a9 --- /dev/null +++ b/client/src/components/molecules/WhiteButtonWithIcon/WhiteButtonWithIcon.stories.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { Story, Meta } from '@storybook/react/types-6-0'; +import SortIcon from '@imgs/sort-icon.png'; +import { WhiteButtonWithIcon, WhiteButtonWithIconProps } from './WhiteButtonWithIcon'; + +export default { + title: 'molecules/WhiteButtonWithIcon', + component: WhiteButtonWithIcon +} as Meta; + +const onClick = () => {}; + +const Template: Story = (args) => ; + +export const BlackWhiteButtonWithIcon = Template.bind({}); +BlackWhiteButtonWithIcon.args = { + children: 'Sort', + iconSrc: SortIcon, + onClick +}; diff --git a/client/src/components/molecules/WhiteButtonWithIcon/WhiteButtonWithIcon.tsx b/client/src/components/molecules/WhiteButtonWithIcon/WhiteButtonWithIcon.tsx new file mode 100644 index 0000000..edbf179 --- /dev/null +++ b/client/src/components/molecules/WhiteButtonWithIcon/WhiteButtonWithIcon.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import styled from 'styled-components'; +import { Button, Text, Icon } from '@components/atoms'; +import { color } from '@theme/index'; + +interface WhiteButtonWithIconProps { + children: React.ReactChild; + iconSrc: string; + onClick: () => void; +} + +const WhiteButtonWithIconWrap = styled.div` + display: flex; + width: fit-content; + p { + margin-left: 0.3rem; + } +`; + +const WhiteButtonWithIcon: React.FC = ({ children, iconSrc, onClick, ...props }) => { + return ( + + + + ); +}; + +export { WhiteButtonWithIcon, WhiteButtonWithIconProps }; diff --git a/client/src/components/molecules/index.ts b/client/src/components/molecules/index.ts index 7012070..3832e54 100644 --- a/client/src/components/molecules/index.ts +++ b/client/src/components/molecules/index.ts @@ -1,10 +1,43 @@ import { ActiveProfileImg } from './ActiveProfileImg/ActiveProfileImg'; -import Channel from './Channel/ChannelContainer'; -import DM from './DM/DMContainer'; +import { Channel } from './Channel/Channel'; +import { DM } from './DM/DM'; import { Section } from './Section/Section'; import { InputMessage } from './InputMessage/InputMessage'; import { SendMessageButton } from './SendMessageButton/SendMessageButton'; import { GithubLoginButton } from './GithubLoginButton/GithubLoginButton'; import { Message } from './Message/Message'; +import { BrowsePageChannelHeader } from './BrowsePageChannelHeader/BrowsePageChannelHeader'; +import { BrowsePageChannelBody } from './BrowsePageChannelBody/BrowsePageChannelBody'; +import { BrowsePageChannelButton } from './BrowsePageChannelButton/BrowsePageChannelButton'; +import { MessageReplyBar } from './MessageReplyBar/MessageReplyBar'; +import { EmojiBox } from './EmojiBox/EmojiBox'; +import { UserBox } from './UserBox/UserBox'; +import { ChannelModal } from './ChannelModal/ChannelModal'; +import { BrowsePageControls } from './BrowsePageControls/BrowsePageControls'; +import { WhiteButtonWithIcon } from './WhiteButtonWithIcon/WhiteButtonWithIcon'; +import { HoverIcon } from './HoverIcon/HoverIcon'; +import { Actionbar } from './Actionbar/Actionbar'; +import { AddChannelButton } from './AddChannelButton/AddChannelButton'; -export { ActiveProfileImg, Channel, DM, Section, InputMessage, SendMessageButton, GithubLoginButton, Message }; +export { + Actionbar, + ActiveProfileImg, + Channel, + DM, + HoverIcon, + Section, + InputMessage, + SendMessageButton, + GithubLoginButton, + Message, + BrowsePageChannelHeader, + BrowsePageChannelBody, + BrowsePageChannelButton, + MessageReplyBar, + EmojiBox, + UserBox, + ChannelModal, + AddChannelButton, + BrowsePageControls, + WhiteButtonWithIcon +}; diff --git a/client/src/components/organisms/BrowsePageChannel/BrowsePageChannel.stories.tsx b/client/src/components/organisms/BrowsePageChannel/BrowsePageChannel.stories.tsx new file mode 100644 index 0000000..1842163 --- /dev/null +++ b/client/src/components/organisms/BrowsePageChannel/BrowsePageChannel.stories.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { Story, Meta } from '@storybook/react/types-6-0'; +import { BrowsePageChannel, BrowsePageChannelProps } from './BrowsePageChannel'; + +export default { + title: 'organisms/BrowsePageChannel', + component: BrowsePageChannel +} as Meta; + +const Template: Story = (args) => ; + +const handlingJoinButton = () => {}; +const handlingLeaveButton = () => {}; + +export const BlackBrowsePageChannel = Template.bind({}); +BlackBrowsePageChannel.args = { + name: 'notice', + isJoined: true, + memberCount: 4, + description: '공지사항을 안내하는 채널', + isPrivate: true, + handlingJoinButton, + handlingLeaveButton +}; diff --git a/client/src/components/organisms/BrowsePageChannel/BrowsePageChannel.tsx b/client/src/components/organisms/BrowsePageChannel/BrowsePageChannel.tsx new file mode 100644 index 0000000..b48e26d --- /dev/null +++ b/client/src/components/organisms/BrowsePageChannel/BrowsePageChannel.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import styled from 'styled-components'; +import { color } from '@theme/index'; +import { BrowsePageChannelHeader } from '@components/molecules/BrowsePageChannelHeader/BrowsePageChannelHeader'; +import { BrowsePageChannelBody } from '@components/molecules/BrowsePageChannelBody/BrowsePageChannelBody'; +import { BrowsePageChannelButton } from '@components/molecules/BrowsePageChannelButton/BrowsePageChannelButton'; + +interface BrowsePageChannelProps { + name: string; + isJoined?: boolean; + memberCount: number; + description?: string; + isPrivate?: boolean; + handlingJoinButton?: () => void; + handlingLeaveButton?: () => void; +} + +const BrowsePageChannelContainer = styled.div` + display: flex; + justify-content: space-between; + padding: 1rem 1rem; + &:hover { + background-color: ${color.hover_primary}; + button { + display: flex; + } + } +`; + +const BrowsePageChannelContent = styled.div` + display: flex; + flex-direction: column; + width: 100%; + margin-left: 0.5rem; +`; + +const ButtonWrap = styled.div` + display: flex; + button { + display: none; + } +`; + +const BrowsePageChannel: React.FC = ({ + name, + isJoined, + memberCount, + description, + isPrivate, + handlingJoinButton, + handlingLeaveButton, + ...props +}) => { + return ( + + + + + + + + + + ); +}; + +export { BrowsePageChannel, BrowsePageChannelProps }; diff --git a/client/src/components/organisms/BrowsePageHeader/BrowsePageHeader.stories.tsx b/client/src/components/organisms/BrowsePageHeader/BrowsePageHeader.stories.tsx new file mode 100644 index 0000000..5e96ff1 --- /dev/null +++ b/client/src/components/organisms/BrowsePageHeader/BrowsePageHeader.stories.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { Story, Meta } from '@storybook/react/types-6-0'; +import { BrowsePageHeader, BrowsePageHeaderProps } from './BrowsePageHeader'; + +export default { + title: 'organisms/BrowsePageHeader', + component: BrowsePageHeader +} as Meta; + +const onClick = () => {}; + +const Template: Story = (args) => ; + +export const BlackBrowsePageHeader = Template.bind({}); +BlackBrowsePageHeader.args = { + onClick +}; diff --git a/client/src/components/organisms/BrowsePageHeader/BrowsePageHeader.tsx b/client/src/components/organisms/BrowsePageHeader/BrowsePageHeader.tsx new file mode 100644 index 0000000..5912e3a --- /dev/null +++ b/client/src/components/organisms/BrowsePageHeader/BrowsePageHeader.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import styled from 'styled-components'; +import { Text, Button } from '@components/atoms'; +import { color } from '@theme/index'; + +interface BrowsePageHeaderProps { + onClick: () => void; +} + +const BrowsePageHeaderWrap = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + height: 10%; + width: 100%; + box-shadow: 0 2px 2px -2px ${color.border_primary}; + background-color: ${color.tertiary}; + z-index: 2; +`; + +const ContentWrap = styled.div` + display: flex; + align-items: center; + padding: 0rem 1rem; +`; + +const BrowsePageHeader: React.FC = ({ onClick, ...props }) => { + return ( + + + + Channel browser + + + + + + + ); +}; + +export { BrowsePageHeader, BrowsePageHeaderProps }; diff --git a/client/src/components/organisms/ChatroomBody/ChatroomBody.tsx b/client/src/components/organisms/ChatroomBody/ChatroomBody.tsx index 36991d5..f7a1395 100644 --- a/client/src/components/organisms/ChatroomBody/ChatroomBody.tsx +++ b/client/src/components/organisms/ChatroomBody/ChatroomBody.tsx @@ -1,31 +1,87 @@ -import React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import styled from 'styled-components'; -import { InputMessage } from '@components/molecules'; +import { InputMessage, Message } from '@components/molecules'; +import { useDispatch } from 'react-redux'; +import { loadNextMessages } from '@store/actions/chatroom-action'; +import { ChatroomEventType } from '@constants/index'; interface ChatroomBodyProps { - children: React.ReactNode; title: string; + messages: Array; + chatRoomId: number | null; } const ChatroomBodyContainter = styled.div` + position: relative; display: flex; flex-direction: column; justify-content: flex-end; - height: 100%; - overflow-y: scroll; + height: 90%; `; const InputMessageWrap = styled.div` + padding-top: 1rem; + overflow-y: scroll; + -ms-overflow-style: none; + ::-webkit-scrollbar { + display: none; + } +`; + +const InputBoxWrap = styled.div` margin: 1rem; `; -const ChatroomBody: React.FC = ({ title, children, ...props }) => { +const ChatroomBody: React.FC = ({ title, messages, chatRoomId, ...props }) => { + const MessageBodyEl = useRef(); + const [eventType, setEventType] = useState(ChatroomEventType.COMMON); + const [lastRequestMessageId, setLastRequestMessageId] = useState(0); + const dispatch = useDispatch(); + const createMessages = () => { + return messages.map((message: any) => ( + + )); + }; + + const getCurrentScroll = (e: any) => { + const offsetMessage: any = messages[0] || null; + if (eventType !== ChatroomEventType.LOADING && e.target.scrollTop <= 40 && offsetMessage !== null) { + if (offsetMessage.messageId === lastRequestMessageId) return; + setEventType(ChatroomEventType.LOADING); + dispatch(loadNextMessages({ chatRoomId, offsetMessage })); + setLastRequestMessageId(offsetMessage.messageId); + } + }; + + const moveScrollToTheBottom = () => { + const { scrollHeight, clientHeight } = MessageBodyEl.current; + const maxScrollTop = scrollHeight - clientHeight; + MessageBodyEl.current.scrollTop = maxScrollTop > 0 ? maxScrollTop : 0; + }; + + useEffect(() => { + if (eventType !== ChatroomEventType.LOADING && eventType !== ChatroomEventType.COMPLETELOADING) moveScrollToTheBottom(); + window.addEventListener('scroll', getCurrentScroll); + return () => window.removeEventListener('scroll', getCurrentScroll); + }); + + useEffect(() => { + if (eventType === ChatroomEventType.LOADING) setEventType(ChatroomEventType.COMPLETELOADING); + }, [messages]); + return ( - - {children} - + + {createMessages()} + + + ); }; diff --git a/client/src/components/organisms/ChatroomHeader/ChatroomHeader.tsx b/client/src/components/organisms/ChatroomHeader/ChatroomHeader.tsx index b8f4dbf..dfc8f37 100644 --- a/client/src/components/organisms/ChatroomHeader/ChatroomHeader.tsx +++ b/client/src/components/organisms/ChatroomHeader/ChatroomHeader.tsx @@ -1,22 +1,24 @@ import React from 'react'; import styled from 'styled-components'; import { Text, Icon } from '@components/atoms'; +import { UserBox, HoverIcon } from '@components/molecules'; import BlueStar from '@imgs/star-blue.png'; import { color } from '@theme/index'; +import userIcon from '@imgs/user-icon.png'; +import DetailIcon from '@imgs/detail-icon.png'; interface ChatroomHeaderProps { - children: React.ReactNode; title: string; + users: Array; } const ChatroomHeaderContainter = styled.div` - position: fixed; display: flex; - justify-content: flex-start; + justify-content: space-between; align-items: center; height: 10%; width: 100%; - border-bottom: 1px solid #e2e2e2; + box-shadow: 0 2px 2px -2px ${color.border_primary}; background-color: ${color.tertiary}; z-index: 2; `; @@ -31,7 +33,15 @@ const IconWrap = styled.div` margin-left: 0.5rem; `; -const ChatroomHeader: React.FC = ({ children, title, ...props }) => { +const MenuContainer = styled.div` + display: flex; + justify-content: space-between; + align-items: baseline; + width: 9rem; + padding: 0rem 1rem; +`; + +const ChatroomHeader: React.FC = ({ title, users, ...props }) => { return ( @@ -40,6 +50,11 @@ const ChatroomHeader: React.FC = ({ children, title, ...pro + + + + + ); }; diff --git a/client/src/components/organisms/CreateChannelModal/CreateChannelModal.tsx b/client/src/components/organisms/CreateChannelModal/CreateChannelModal.tsx index 8367d74..e292d54 100644 --- a/client/src/components/organisms/CreateChannelModal/CreateChannelModal.tsx +++ b/client/src/components/organisms/CreateChannelModal/CreateChannelModal.tsx @@ -1,7 +1,10 @@ -import React from 'react'; +import React, { useState } from 'react'; import styled from 'styled-components'; import { Text, ModalBox, HoverInput, Button } from '@components/atoms'; import { color } from '@theme/index'; +import { useSelector, useDispatch } from 'react-redux'; +import { createModalClose } from '@store/actions/modal-action'; +import { addChannel } from '@store/actions/chatroom-action'; interface CreateChannelModalProps {} @@ -34,53 +37,88 @@ const SubmitButtonCantainer = styled.div` `; const CreateChannelModal: React.FC = ({ ...props }) => { + const dispatch = useDispatch(); + const isOpen = useSelector((store: any) => store.modal.createModal.isOpen); + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [isPrivate, setIsPrivate] = useState(false); + + const onChangeTitle = (e: any) => { + setTitle(e.target.value); + }; + + const onChangeDescription = (e: any) => { + setDescription(e.target.value); + }; + + const onChangeIsPrivate = () => { + setIsPrivate(!isPrivate); + }; + + const handlingCreateButtonClick = async () => { + try { + if (!title.trim()) throw new Error(); + + const payload = { title, description, isPrivate }; + + dispatch(addChannel(payload)); + dispatch(createModalClose()); + } catch (err) { + alert('채널 이름을 입력해주세요.'); + } + }; + return ( - - - - - Create a Channel - - - - - Channels are where your team communicates. They’re best when organized around a topic — #marketing, for example - - - - - Name - - - - - - - - Description - - - - - - What’s this channel about? - - - - - - Make private - - - - - - - - - + <> + {isOpen ? ( + dispatch(createModalClose())} {...props}> + + + + Create a Channel + + + + + Channels are where your team communicates. They’re best when organized around a topic — #marketing, for example + + + + + Name + + + + + + + + Description + + + + + + What’s this channel about? + + + + + + Make private + + + + + + + + + + ) : null} + ); }; diff --git a/client/src/components/organisms/Header/Header.tsx b/client/src/components/organisms/Header/Header.tsx index 8fe0a63..90d8863 100644 --- a/client/src/components/organisms/Header/Header.tsx +++ b/client/src/components/organisms/Header/Header.tsx @@ -1,12 +1,13 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import styled from 'styled-components'; import { LogoImg } from '@components/atoms'; import { ActiveProfileImg } from '@components/molecules'; import { color } from '@theme/index'; +import { useDispatch, useSelector } from 'react-redux'; +import { RootState } from '@store/reducers'; +import { loginAsync } from '@store/actions/user-action'; -interface HeaderProps { - profileUri?: string; -} +interface HeaderProps {} const HeaderContainter = styled.div` display: flex; @@ -18,7 +19,13 @@ const HeaderContainter = styled.div` box-shadow: 0 1px 0 0 ${color.box_shadow_tertiary}; `; -const Header: React.FC = ({ profileUri, ...props }) => { +const Header: React.FC = ({ ...props }) => { + const { profileUri } = useSelector((state: RootState) => state.user); + const dispatch = useDispatch(); + useEffect(() => { + dispatch(loginAsync()); + }, []); + return ( diff --git a/client/src/components/organisms/Header/HeaderContainer.tsx b/client/src/components/organisms/Header/HeaderContainer.tsx deleted file mode 100644 index 6190f29..0000000 --- a/client/src/components/organisms/Header/HeaderContainer.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { connect } from 'react-redux'; -import { Header } from './Header'; - -function mapReduxStateToReactProps(state: any) { - return { - profileUri: state.userData.profileUri - }; -} - -export default connect(mapReduxStateToReactProps)(Header); diff --git a/client/src/components/organisms/Sidebar/Sidebar.stories.tsx b/client/src/components/organisms/Sidebar/Sidebar.stories.tsx index 3442aa2..f5e7905 100644 --- a/client/src/components/organisms/Sidebar/Sidebar.stories.tsx +++ b/client/src/components/organisms/Sidebar/Sidebar.stories.tsx @@ -17,8 +17,8 @@ const mockDMChildren = ['J003_강동훈', 'J030_김도호', 'J211_탁성건'].ma }); const mockChildren = [ -
{mockChannelChildren}
, -
{mockDMChildren}
+
{mockChannelChildren}
, +
{mockDMChildren}
]; const Template: Story = (args) => ; diff --git a/client/src/components/organisms/Sidebar/Sidebar.tsx b/client/src/components/organisms/Sidebar/Sidebar.tsx index 16db4e8..6154133 100644 --- a/client/src/components/organisms/Sidebar/Sidebar.tsx +++ b/client/src/components/organisms/Sidebar/Sidebar.tsx @@ -1,16 +1,15 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import styled from 'styled-components'; import { Text } from '@components/atoms'; import { Channel, DM, Section } from '@components/molecules'; import { color } from '@theme/index'; +import { useDispatch, useSelector } from 'react-redux'; +import { initSidebarAsync } from '@store/actions/chatroom-action'; +import { RootState } from '@store/reducers'; +import { DefaultSectionName } from '@constants/default-section-name'; interface SidebarProps { children?: React.ReactNode; - starred: Array; - otherSections: Array; - channels: Array; - directMessages: Array; - selectedChatroomId?: number; } const StyledSidebar = styled.div` @@ -24,14 +23,32 @@ const Workspace = styled.div` align-items: center; height: 10%; padding-left: 1rem; - border-bottom: 1px solid ${color.sidebar_border}; + box-shadow: 0 1.5px 2px -2px ${color.border_primary}; `; const ChildrenWrap = styled.div` - padding: 1rem; + overflow-y: scroll; + padding: 0rem 1rem; + height: 90%; + -ms-overflow-style: none; + ::-webkit-scrollbar { + display: none; + } `; -const Sidebar: React.FC = ({ children, starred, otherSections, channels, directMessages, selectedChatroomId, ...props }) => { +const SectionWrap = styled.div` + padding: 1rem 0rem; +`; + +const Sidebar: React.FC = ({ children, ...props }) => { + const dispatch = useDispatch(); + + const { starred, otherSections, channels, directMessages, selectedChatroomId } = useSelector((store: RootState) => store.chatroom); + + useEffect(() => { + dispatch(initSidebarAsync()); + }, []); + const createSection = (sectionItems: Array, sectionName: string) => { if (sectionItems.length === 0) return null; const chatrooms = sectionItems.map((chatroom) => @@ -53,7 +70,7 @@ const Sidebar: React.FC = ({ children, starred, otherSections, cha ) ); - return
{chatrooms}
; + return
{chatrooms}
; }; return ( @@ -64,9 +81,11 @@ const Sidebar: React.FC = ({ children, starred, otherSections, cha - {createSection(starred, 'Starred')} - {createSection(channels, 'Channels')} - {createSection(directMessages, 'Direct Messages')} + + {createSection(starred, DefaultSectionName.STARRED)} + {createSection(channels, DefaultSectionName.CHANNELS)} + {createSection(directMessages, DefaultSectionName.DIRECT_MESSAGES)} + ); diff --git a/client/src/components/organisms/Sidebar/SidebarContainer.tsx b/client/src/components/organisms/Sidebar/SidebarContainer.tsx deleted file mode 100644 index 5913d10..0000000 --- a/client/src/components/organisms/Sidebar/SidebarContainer.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { connect } from 'react-redux'; -import { Sidebar } from './Sidebar'; - -function mapReduxStateToReactProps(state: any) { - return { - starred: state.sidebarData.starred, - otherSections: state.sidebarData.otherSections, - channels: state.sidebarData.channels, - directMessages: state.sidebarData.directMessages, - selectedChatroomId: state.sidebarData.selectedChatroomId - }; -} - -export default connect(mapReduxStateToReactProps)(Sidebar); diff --git a/client/src/components/organisms/index.ts b/client/src/components/organisms/index.ts index 70c8d98..5524e1c 100644 --- a/client/src/components/organisms/index.ts +++ b/client/src/components/organisms/index.ts @@ -1,7 +1,9 @@ -import Header from './Header/HeaderContainer'; -import Sidebar from './Sidebar/SidebarContainer'; +import { Header } from './Header/Header'; +import { Sidebar } from './Sidebar/Sidebar'; import { ChatroomHeader } from './ChatroomHeader/ChatroomHeader'; import { ChatroomBody } from './ChatroomBody/ChatroomBody'; import { LoginForm } from './LoginForm/LoginForm'; +import { BrowsePageChannel } from './BrowsePageChannel/BrowsePageChannel'; +import { CreateChannelModal } from './CreateChannelModal/CreateChannelModal'; -export { Header, Sidebar, ChatroomHeader, ChatroomBody, LoginForm }; +export { Header, Sidebar, ChatroomHeader, ChatroomBody, LoginForm, BrowsePageChannel, CreateChannelModal }; diff --git a/client/src/components/templates/Body.tsx b/client/src/components/templates/Body.tsx new file mode 100644 index 0000000..cfb9a68 --- /dev/null +++ b/client/src/components/templates/Body.tsx @@ -0,0 +1,20 @@ +import { channelModalClose } from '@store/actions/modal-action'; +import React from 'react'; +import { useDispatch } from 'react-redux'; +import styled from 'styled-components'; + +const StyledBody = styled.div` + position: relative; + width: 100%; + height: 100%; +`; + +const Body: React.FC = ({ children }) => { + const dispatch = useDispatch(); + const handlingLeave = () => { + dispatch(channelModalClose()); + }; + return {children}; +}; + +export default Body; diff --git a/client/src/components/templates/index.ts b/client/src/components/templates/index.ts index 4e6d852..8acda86 100644 --- a/client/src/components/templates/index.ts +++ b/client/src/components/templates/index.ts @@ -1,5 +1,6 @@ import FlexContainer from './FlexContainer'; import Main from './Main'; import MainBox from './MainBox'; +import Body from './Body'; -export { FlexContainer, Main, MainBox }; +export { FlexContainer, Main, MainBox, Body }; diff --git a/client/src/pages/Chatroom/Chatroom.tsx b/client/src/pages/Chatroom/Chatroom.tsx index b800d86..ca12bed 100644 --- a/client/src/pages/Chatroom/Chatroom.tsx +++ b/client/src/pages/Chatroom/Chatroom.tsx @@ -1,26 +1,35 @@ import React, { useEffect } from 'react'; import styled from 'styled-components'; -import { getChatroomInfo } from '@dispatch/index'; import { ChatroomHeader, ChatroomBody } from '@components/organisms'; +import { useDispatch, useSelector } from 'react-redux'; +import { RootState } from '@store/reducers/index'; +import { insertMessage, loadAsync } from '@store/actions/chatroom-action'; +import socket from '@socket/socketIO'; interface ChatroomProps { children: React.ReactNode; - title: string; - selectedChatroomId: number; } const ChatroomContainer = styled.div` height: 100%; `; -const Chatroom: React.FC = ({ title, selectedChatroomId, children, ...props }) => { +const Chatroom: React.FC = ({ children, ...props }) => { + const dispatch = useDispatch(); + const { selectedChatroomId, selectedChatroom, messages } = useSelector((store: RootState) => store.chatroom); + const { title, users } = selectedChatroom; + useEffect(() => { - getChatroomInfo(selectedChatroomId); + dispatch(loadAsync({ selectedChatroomId })); + socket.on('create message', (message: any) => { + dispatch(insertMessage(message)); + }); }, []); + return ( - {} - {} + + ); }; diff --git a/client/src/pages/Chatroom/ChatroomContainer.tsx b/client/src/pages/Chatroom/ChatroomContainer.tsx deleted file mode 100644 index 40468a6..0000000 --- a/client/src/pages/Chatroom/ChatroomContainer.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { connect } from 'react-redux'; -import { Chatroom } from './Chatroom'; - -function mapReduxStateToReactProps(state: any) { - return { - selectedChatroomId: state.sidebarData.selectedChatroomId, - title: state.chatroomData.title - }; -} - -export default connect(mapReduxStateToReactProps)(Chatroom); diff --git a/client/src/pages/index.ts b/client/src/pages/index.ts index 6f9664e..c82a377 100644 --- a/client/src/pages/index.ts +++ b/client/src/pages/index.ts @@ -1,4 +1,4 @@ -import Chatroom from './Chatroom/ChatroomContainer'; +import { Chatroom } from './Chatroom/Chatroom'; import { Login } from './Login'; export { Chatroom, Login }; diff --git a/client/src/shared/App.tsx b/client/src/shared/App.tsx index dbcca10..5e2d82b 100644 --- a/client/src/shared/App.tsx +++ b/client/src/shared/App.tsx @@ -1,17 +1,15 @@ import React, { Fragment, useEffect } from 'react'; import { BrowserRouter, Switch, Route } from 'react-router-dom'; import { Chatroom, Login } from '@pages/index'; -import { Header, Sidebar } from '@components/organisms'; +import { Header, Sidebar, CreateChannelModal } from '@components/organisms'; import { registerToken, blockPage } from '@utils/index'; -import { getUserInfo, getUserChatroom } from '@dispatch/index'; -import { Main, MainBox } from '@components/templates'; +import { Main, MainBox, Body } from '@components/templates'; +import { ChannelModal } from '@components/molecules'; const App = () => { useEffect(() => { registerToken().then(() => { blockPage(); - getUserInfo(); - getUserChatroom(); }); }, []); @@ -20,14 +18,18 @@ const App = () => { -
-
- - - - - -
+ +
+
+ + + + + +
+ + + diff --git a/client/tsconfig.json b/client/tsconfig.json index 2513be0..6d31618 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -19,8 +19,9 @@ "@theme/*": ["src/common/theme/*"], "@utils/*": ["src/common/utils/*"], "@store/*": ["src/common/store/*"], - "@dispatch/*": ["src/common/dispatch/*"], - "@imgs/*": ["public/imgs/*"] + "@imgs/*": ["public/imgs/*"], + "@socket/*": ["src/common/socket/*"], + "@constants/*": ["src/common/constants/*"] } }, "exclude": ["node_modules"], diff --git a/client/webpack.config.js b/client/webpack.config.js index 42337f8..4732ba7 100644 --- a/client/webpack.config.js +++ b/client/webpack.config.js @@ -5,9 +5,9 @@ const HtmlWebpackPlugin = require('html-webpack-plugin'); const { CleanWebpackPlugin } = require('clean-webpack-plugin'); module.exports = (env, options) => { - dotenv.config({ + const fileEnv = dotenv.config({ path: `./env/${options.stage || 'development'}.env` - }); + }).parsed; return { mode: process.env.NODE_ENV, @@ -57,12 +57,14 @@ module.exports = (env, options) => { '@theme': path.resolve(__dirname, 'src/common/theme'), '@utils': path.resolve(__dirname, 'src/common/utils'), '@store': path.resolve(__dirname, 'src/common/store'), - '@dispatch': path.resolve(__dirname, 'src/common/dispatch'), - '@imgs': path.resolve(__dirname, 'public/imgs') + '@imgs': path.resolve(__dirname, 'public/imgs'), + '@socket': path.resolve(__dirname, 'src/common/socket'), + '@constants': path.resolve(__dirname, 'src/common/constants') } }, plugins: [ new webpack.HotModuleReplacementPlugin(), + new webpack.DefinePlugin({ 'process.env': JSON.stringify(fileEnv) }), new HtmlWebpackPlugin({ filename: 'index.html', template: './public/index.html', diff --git a/server/package-lock.json b/server/package-lock.json index cfa3f36..54d91a2 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -132,6 +132,11 @@ "@types/node": "*" } }, + "@types/component-emitter": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@types/component-emitter/-/component-emitter-1.2.10.tgz", + "integrity": "sha512-bsjleuRKWmGqajMerkzox19aGbscQX5rmmvvXl3wlIp5gMG1HgkiwPxsN5p070fBDKTNSPgojVbuY1+HWMbFhg==" + }, "@types/connect": { "version": "3.4.33", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.33.tgz", @@ -140,6 +145,11 @@ "@types/node": "*" } }, + "@types/cookie": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.0.tgz", + "integrity": "sha512-y7mImlc/rNkvCRmg8gC3/lj87S7pTUIJ6QGjwHR9WQJcFs+ZMTOaoPrkdFA/YdbuqVEmEbb5RdhVxMkAcgOnpg==" + }, "@types/cookie-parser": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.2.tgz", @@ -153,7 +163,6 @@ "version": "2.8.8", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.8.tgz", "integrity": "sha512-fO3gf3DxU2Trcbr75O7obVndW/X5k8rJNZkLXlQWStTHhP71PkRqjwPIEI0yMnJdg9R9OasjU+Bsr+Hr1xy/0w==", - "dev": true, "requires": { "@types/express": "*" } @@ -590,6 +599,11 @@ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" }, + "base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==" + }, "base64url": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", @@ -1139,6 +1153,11 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" }, + "component-emitter": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", + "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==" + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1385,6 +1404,40 @@ "once": "^1.4.0" } }, + "engine.io": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-4.0.4.tgz", + "integrity": "sha512-4ggUX5pICZU17OTZNFv5+uFE/ZyoK+TIXv2SvxWWX8lwStllQ6Lvvs4lDBqvKpV9EYXNcvlNOcjKChd/mo+8Tw==", + "requires": { + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.4.1", + "cors": "~2.8.5", + "debug": "~4.1.0", + "engine.io-parser": "~4.0.0", + "ws": "^7.1.2" + }, + "dependencies": { + "cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==" + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "requires": { + "ms": "^2.1.1" + } + } + } + }, + "engine.io-parser": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-4.0.1.tgz", + "integrity": "sha512-v5aZK1hlckcJDGmHz3W8xvI3NUHYc9t8QtTbqdR5OaH3S9iJZilPubauOm+vLWOMMWzpE3hiq92l9lTAHamRCg==" + }, "enquirer": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz", @@ -3709,6 +3762,57 @@ "is-fullwidth-code-point": "^2.0.0" } }, + "socket.io": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-3.0.3.tgz", + "integrity": "sha512-TC1GnSXhDVmd3bHji5aG7AgWB8UL7E6quACbKra8uFXBqlMwEDbrJFK+tjuIY5Pe9N0L+MAPPDv3pycnn0000A==", + "requires": { + "@types/cookie": "^0.4.0", + "@types/cors": "^2.8.8", + "@types/node": "^14.14.7", + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "debug": "~4.1.0", + "engine.io": "~4.0.0", + "socket.io-adapter": "~2.0.3", + "socket.io-parser": "~4.0.1" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "requires": { + "ms": "^2.1.1" + } + } + } + }, + "socket.io-adapter": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.0.3.tgz", + "integrity": "sha512-2wo4EXgxOGSFueqvHAdnmi5JLZzWqMArjuP4nqC26AtLh5PoCPsaRbRdah2xhcwTAMooZfjYiNVNkkmmSMaxOQ==" + }, + "socket.io-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.0.2.tgz", + "integrity": "sha512-Bs3IYHDivwf+bAAuW/8xwJgIiBNtlvnjYRc4PbXgniLmcP1BrakBoq/QhO24rgtgW7VZ7uAaswRGxutUnlAK7g==", + "requires": { + "@types/component-emitter": "^1.2.10", + "component-emitter": "~1.3.0", + "debug": "~4.1.0" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "requires": { + "ms": "^2.1.1" + } + } + } + }, "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -4486,6 +4590,11 @@ "typedarray-to-buffer": "^3.1.5" } }, + "ws": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.1.tgz", + "integrity": "sha512-pTsP8UAfhy3sk1lSk/O/s4tjD0CRwvMnzvwr4OKGX7ZvqZtUyx4KIJB5JWbkykPoc55tixMGgTNoh3k4FkNGFQ==" + }, "xdg-basedir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", diff --git a/server/package.json b/server/package.json index b7d13e5..e5b4056 100644 --- a/server/package.json +++ b/server/package.json @@ -52,6 +52,7 @@ "randomstring": "^1.1.5", "redis": "^3.0.2", "reflect-metadata": "^0.1.13", + "socket.io": "^3.0.3", "typeorm": "^0.2.29", "typeorm-seeding": "^1.6.1", "typeorm-transactional-cls-hooked": "^0.1.12" diff --git a/server/src/application.ts b/server/src/application.ts index 4c1976a..93f2e93 100644 --- a/server/src/application.ts +++ b/server/src/application.ts @@ -11,6 +11,8 @@ import logger from 'morgan'; import passportConfig from '@config/passport'; import cookieParser from 'cookie-parser'; import redis from '@middleware/redis'; +import Socket from '@socket/socket'; +import initSocketIo from '@socket/init-socket'; export default class Application { app: Express; @@ -56,9 +58,11 @@ export default class Application { listen() { const PORT = process.env.PORT || 3000; - this.app.listen(PORT, () => { + const server = this.app.listen(PORT, () => { console.log(`server is running on ${PORT}`); }); + const io = Socket(server); + initSocketIo(io); } } diff --git a/server/src/common/constants/event-name.ts b/server/src/common/constants/event-name.ts new file mode 100644 index 0000000..d75115c --- /dev/null +++ b/server/src/common/constants/event-name.ts @@ -0,0 +1,9 @@ +const enum eventName { + CREATE_MESSAGE = 'create message', + UPDATE_MESSAGE = 'update message', + DELETE_MESSAGE = 'delete message', + CREATE_REPLY = 'create reply', + UPDATE_REPLY = 'update reply', + DELETE_REPLY = 'delete reply' +} +export default eventName; diff --git a/server/src/controller/chatroom-controller.ts b/server/src/controller/chatroom-controller.ts index 1fef654..7600e4e 100644 --- a/server/src/controller/chatroom-controller.ts +++ b/server/src/controller/chatroom-controller.ts @@ -23,10 +23,20 @@ const ChatroomController = { next(err); } }, + async getChatrooms(req: Request, res: Response, next: NextFunction) { + try { + const { userId } = req.user; + const chatroom = await ChatroomService.getInstance().getChatrooms(Number(userId)); + res.status(HttpStatusCode.OK).json(chatroom); + } catch (err) { + next(err); + } + }, async getChatroomInfo(req: Request, res: Response, next: NextFunction) { try { + const { userId } = req.user; const { chatroomId } = req.params; - const chatroomInfo = await ChatroomService.getInstance().getChatroomInfo(Number(chatroomId)); + const chatroomInfo = await ChatroomService.getInstance().getChatroomInfo(Number(chatroomId), Number(userId)); res.status(HttpStatusCode.OK).json(chatroomInfo); } catch (err) { next(err); diff --git a/server/src/model/chatroom.ts b/server/src/model/chatroom.ts index 1139835..19f932b 100644 --- a/server/src/model/chatroom.ts +++ b/server/src/model/chatroom.ts @@ -36,7 +36,7 @@ export default class Chatroom { @DeleteDateColumn() deletedAt: Date; - @OneToMany(() => UserChatroom, (userChatroom) => userChatroom.user) + @OneToMany(() => UserChatroom, (userChatroom) => userChatroom.chatroom) userChatrooms: UserChatroom[]; @OneToMany(() => Message, (message) => message.chatroom) diff --git a/server/src/router/chatroom-router.ts b/server/src/router/chatroom-router.ts index 9ef36b7..0f82ba1 100644 --- a/server/src/router/chatroom-router.ts +++ b/server/src/router/chatroom-router.ts @@ -3,6 +3,8 @@ import ChatroomController from '@controller/chatroom-controller'; const router = express.Router(); + +router.get('/', ChatroomController.getChatrooms); router.post('/channel', ChatroomController.createChannel); router.post('/dm', ChatroomController.createDM); router.get('/:chatroomId', ChatroomController.getChatroomInfo); diff --git a/server/src/router/message-router.ts b/server/src/router/message-router.ts index 3a5d809..ca67e9b 100644 --- a/server/src/router/message-router.ts +++ b/server/src/router/message-router.ts @@ -8,4 +8,5 @@ router.get('/:chatRoomId', messageController.getMessages); router.post('/', messageController.createMessage); router.patch('/:messageId', messageController.updateMessage); router.delete('/:messageId', messageController.deleteMessage); + export default router; diff --git a/server/src/service/chatroom-service.ts b/server/src/service/chatroom-service.ts index 0d18f9b..6bb5ae9 100644 --- a/server/src/service/chatroom-service.ts +++ b/server/src/service/chatroom-service.ts @@ -108,8 +108,43 @@ class ChatroomService { return newUserChatroom; } + async getChatrooms(userId: Number) { + const chatrooms = await this.chatroomRepository + .createQueryBuilder('chatroom') + .where('chatroom.chatType = :chatType', { chatType: 'Channel' }) + .leftJoin('chatroom.userChatrooms', 'userChatrooms') + .leftJoin('userChatrooms.user', 'user') + .select(['chatroom.chatroomId', 'chatroom.title', 'chatroom.description', 'chatroom.isPrivate']) + .addSelect(['userChatrooms.userChatroomId']) + .addSelect(['user.userId']) + .orderBy('chatroom.title') + .getMany(); + const filterChatrooms = this.getFilterPrivateChatrooms(chatrooms, userId); + const customChatrooms = this.getCustomChatrooms(filterChatrooms); + return customChatrooms; + } + + private getFilterPrivateChatrooms(chatrooms: any, userId: Number) { + return chatrooms.filter((chatroom) => { + let isJoin; + if (chatroom.isPrivate) + chatroom.userChatrooms.forEach((userChatroom) => { + if (userChatroom.user.userId === userId) isJoin = true; + }); + return !chatroom.isPrivate || isJoin; + }); + } + + private getCustomChatrooms(chatrooms: any) { + return chatrooms.map((chatroom) => { + const { chatroomId, title, description, isPrivate, userChatrooms } = chatroom; + const members = userChatrooms.length; + return { chatroomId, title, description, isPrivate, members }; + }); + } + @Transactional() - async getChatroomInfo(chatroomId: number) { + async getChatroomInfo(chatroomId: number, userId: number) { const chatroom = await this.chatroomRepository .createQueryBuilder('chatroom') .where('chatroom.chatroomId = :chatroomId', { chatroomId }) @@ -124,22 +159,43 @@ class ChatroomService { .createQueryBuilder('userChatroom') .where('userChatroom.chatroomId = :chatroomId', { chatroomId }) .leftJoinAndSelect('userChatroom.user', 'user') + .select(['userChatroom', 'user.userId', 'user.profileUri', 'user.displayName']) .getMany(); if (!userChatrooms) { throw new NotFoundError(); } - const users = userChatrooms.map((userChatroom) => { - const { userId, profileUri, displayName } = userChatroom.user; - return { userId, profileUri, displayName }; - }); + const users = userChatrooms.map((userChatroom) => userChatroom.user); const userCount = users.length; + const { chatType } = chatroom; + + if (chatType === ChatType.DM) { + const title = this.findTitle(users, userId); + const { description, isPrivate, topic } = chatroom; + return { title, description, isPrivate, chatType, topic, userCount, users }; + } return { ...chatroom, userCount, users }; } + private findTitle(users: any[], userId: number) { + if (users.every((user) => user.userId === userId)) { + const { displayName } = users[0]; + return displayName; + } + + const title = users + .filter((user) => user.userId !== userId) + .reduce((str, user) => { + if (!str) return user.displayName; + return `${str}, ${user.displayName}`; + }, ''); + + return title; + } + async updateChatroom(chatroomId: number, title: string, topic: string, description: string) { const chatroom = this.chatroomRepository.findOne({ chatroomId }); diff --git a/server/src/service/message-service.ts b/server/src/service/message-service.ts index e4160f0..dd0f1f4 100644 --- a/server/src/service/message-service.ts +++ b/server/src/service/message-service.ts @@ -3,6 +3,7 @@ import MessageRepository from '@repository/message-repository'; import UserRepository from '@repository/user-repository'; import ChatroomRepository from '@repository/chatroom-repository'; +import BadRequestError from '@error/bad-request-error'; import validator from '../common/utils/validator'; class MessageService { @@ -30,6 +31,11 @@ class MessageService { async createMessage(userId: number, chatroomId: number, content: string) { const user = await this.UserRepository.findOne(userId); const chatroom = await this.ChatroomRepository.findOne(chatroomId); + + if (!user || !chatroom) { + throw new BadRequestError(); + } + const message = await this.MessageRepository.create({ user, chatroom, content }); validator(message); const { messageId } = await this.MessageRepository.save(message); @@ -39,14 +45,16 @@ class MessageService { async getMessage(messageId: number) { const message = await this.MessageRepository.createQueryBuilder('message') .leftJoinAndSelect('message.user', 'user') - .select(['message', 'user.userId', 'user.profileUri', 'user.displayName']) + .leftJoinAndSelect('message.chatroom', 'chatroom') + .select(['message', 'user.userId', 'user.profileUri', 'user.displayName', 'chatroom.chatroomId']) .where('message.messageId = :messageId', { messageId }) .getOne(); + if (!message) throw new BadRequestError(); return message; } async getMessages(chatroomId: number, offsetId: number) { - const limit = 10; + const limit = 15; const messages = await this.MessageRepository.createQueryBuilder('message') .leftJoin('message.user', 'user') .leftJoin('message.replies', 'replies') @@ -54,7 +62,7 @@ class MessageService { .leftJoin('message.messageReactions', 'messageReactions') .leftJoin('messageReactions.reaction', 'reaction') .leftJoin('messageReactions.user', 'reactionUser') - .select(['message.messageId', 'message.createdAt', 'message.updatedAt']) + .select(['message.messageId', 'message.createdAt', 'message.updatedAt', 'message.content']) .addSelect(['user.userId', 'user.profileUri', 'user.displayName']) .addSelect(['messageReactions.messageReactionId']) .addSelect(['reaction.reactionId', 'reaction.title', 'reaction.imageUri']) @@ -69,11 +77,11 @@ class MessageService { this.customMessagesReaction(messages); this.customMessagesReplies(messages); - return messages; + return messages.reverse(); } async getRecentMessages(chatroomId: number) { - const limit = 10; + const limit = 15; const messages = await this.MessageRepository.createQueryBuilder('message') .leftJoin('message.user', 'user') .leftJoin('message.replies', 'replies') @@ -81,7 +89,7 @@ class MessageService { .leftJoin('message.messageReactions', 'messageReactions') .leftJoin('messageReactions.reaction', 'reaction') .leftJoin('messageReactions.user', 'reactionUser') - .select(['message.messageId', 'message.createdAt', 'message.updatedAt']) + .select(['message.messageId', 'message.createdAt', 'message.updatedAt', 'message.content']) .addSelect(['user.userId', 'user.profileUri', 'user.displayName']) .addSelect(['messageReactions.messageReactionId']) .addSelect(['reaction.reactionId', 'reaction.title', 'reaction.imageUri']) @@ -96,13 +104,14 @@ class MessageService { this.customMessagesReaction(messages); this.customMessagesReplies(messages); - return messages; + return messages.reverse(); } async updateMessage(messageId: number, content: string) { const message = await this.MessageRepository.create({ messageId, content }); validator(message); - await this.MessageRepository.save({ messageId, content }); + const updatedMessage = await this.MessageRepository.save({ messageId, content }); + return updatedMessage; } async deleteMessage(messageId: number) { diff --git a/server/src/service/reply-service.ts b/server/src/service/reply-service.ts index 7570488..33697a8 100644 --- a/server/src/service/reply-service.ts +++ b/server/src/service/reply-service.ts @@ -41,6 +41,19 @@ class ReplyService { return newReply.replyId; } + async getReplyInfo(replyId: number) { + const reply = await this.replyRepository + .createQueryBuilder('reply') + .leftJoinAndSelect('reply.message', 'message') + .leftJoinAndSelect('message.chatroom', 'chatroom') + .where('reply.replyId = :replyId', { replyId }) + .select(['reply.replyId']) + .addSelect(['chatroom.chatroomId']) + .addSelect(['message.messageId']) + .getOne(); + return reply; + } + async getReply(replyId: number) { const reply = await this.replyRepository .createQueryBuilder('reply') @@ -123,6 +136,17 @@ class ReplyService { return Object.values(reactions); } + + async updateReply(replyId: number, content: string) { + const reply = await this.replyRepository.create({ replyId, content }); + await validator(reply); + const updatedReply = await this.replyRepository.save(reply); + return updatedReply; + } + + async deleteReply(replyId: number) { + await this.replyRepository.softDelete(replyId); + } } export default ReplyService; diff --git a/server/src/service/user-chatroom-service.ts b/server/src/service/user-chatroom-service.ts index ee0f5cc..fb9b49e 100644 --- a/server/src/service/user-chatroom-service.ts +++ b/server/src/service/user-chatroom-service.ts @@ -138,7 +138,7 @@ class UserChatroomService { throw new BadRequestError(); } - await this.saveChatroom(user, chatroom); + await this.saveUserChatroom(user, chatroom); } @Transactional() @@ -161,7 +161,7 @@ class UserChatroomService { throw new BadRequestError(); } - await this.saveChatroom(user, chatroom); + await this.saveUserChatroom(user, chatroom); }) ); } @@ -170,13 +170,13 @@ class UserChatroomService { const userChatroom = await this.userChatroomRepository .createQueryBuilder('userChatroom') .where('userChatroom.user.userId = :userId', { userId }) - .where('userChatroom.chatroom.chatroomId = :chatroomId', { chatroomId }) + .andWhere('userChatroom.chatroom.chatroomId = :chatroomId', { chatroomId }) .getOne(); return !!userChatroom; } - private async saveChatroom(user, chatroom) { + private async saveUserChatroom(user, chatroom) { const sectionName = chatroom.chatType === ChatType.DM ? DefaultSectionName.DirectMessages : DefaultSectionName.Channels; const newUserChatroom = this.userChatroomRepository.create({ user, chatroom, sectionName }); await validator(newUserChatroom); diff --git a/server/src/socket/event/connection-event.ts b/server/src/socket/event/connection-event.ts new file mode 100644 index 0000000..dfe4ba1 --- /dev/null +++ b/server/src/socket/event/connection-event.ts @@ -0,0 +1,7 @@ +import chatroomHandler from '@socket/handler/chatroom-handler'; + +const connectionEvent = (io, socket) => { + chatroomHandler.joinChatroom(io, socket); +}; + +export default connectionEvent; diff --git a/server/src/socket/event/message-event.ts b/server/src/socket/event/message-event.ts new file mode 100644 index 0000000..b27e58b --- /dev/null +++ b/server/src/socket/event/message-event.ts @@ -0,0 +1,10 @@ +import messageHandler from '@socket/handler/message-handler'; +import eventName from '@constants/event-name'; + +const messageEvent = (io, socket) => { + socket.on(eventName.CREATE_MESSAGE, (message) => messageHandler.createMessage(io, socket, message)); + socket.on(eventName.UPDATE_MESSAGE, (message) => messageHandler.updateMessage(io, socket, message)); + socket.on(eventName.DELETE_MESSAGE, (message) => messageHandler.deleteMessage(io, socket, message)); +}; + +export default messageEvent; diff --git a/server/src/socket/event/reply-event.ts b/server/src/socket/event/reply-event.ts new file mode 100644 index 0000000..fe21d9f --- /dev/null +++ b/server/src/socket/event/reply-event.ts @@ -0,0 +1,10 @@ +import replyHandler from '@socket/handler/reply-handler'; +import eventName from '@constants/event-name'; + +const messageEvent = (io, socket) => { + socket.on(eventName.CREATE_REPLY, (reply) => replyHandler.createReply(io, socket, reply)); + socket.on(eventName.UPDATE_REPLY, (reply) => replyHandler.updateReply(io, socket, reply)); + socket.on(eventName.DELETE_REPLY, (reply) => replyHandler.deleteReply(io, socket, reply)); +}; + +export default messageEvent; diff --git a/server/src/socket/handler/chatroom-handler.ts b/server/src/socket/handler/chatroom-handler.ts new file mode 100644 index 0000000..07ee009 --- /dev/null +++ b/server/src/socket/handler/chatroom-handler.ts @@ -0,0 +1,20 @@ +import UserChatroomService from '@service/user-chatroom-service'; + +const chatroomHandler = { + async joinChatroom(io, socket) { + const req = socket.request; + const { userId } = req.user; + const userChatrooms = await UserChatroomService.getInstance().getUserChatrooms(userId); + const keys = Object.values(userChatrooms); + + keys.forEach((chatrooms) => { + if (chatrooms) + chatrooms.forEach((chatroom) => { + socket.join(String(chatroom.chatroomId)); + }); + }); + } +}; + +export default chatroomHandler; + diff --git a/server/src/socket/handler/message-handler.ts b/server/src/socket/handler/message-handler.ts new file mode 100644 index 0000000..a25de05 --- /dev/null +++ b/server/src/socket/handler/message-handler.ts @@ -0,0 +1,29 @@ +import MessageService from '@service/message-service'; +import eventName from '@constants/event-name'; + +const messageHandler = { + async createMessage(io, socket, message) { + const req = socket.request; + const { chatroomId, content } = message; + const { userId } = req.user; + const messageId = await MessageService.getInstance().createMessage(userId, chatroomId, content); + const newMessage = await MessageService.getInstance().getMessage(messageId); + io.to(String(chatroomId)).emit(eventName.CREATE_MESSAGE, { ...newMessage, chatroomId }); + }, + async updateMessage(io, socket, message) { + const { messageId, content } = message; + const messageInfo = await MessageService.getInstance().getMessage(messageId); + const { chatroomId } = messageInfo.chatroom; + const updateMessage = await MessageService.getInstance().updateMessage(messageId, content); + io.to(String(chatroomId)).emit(eventName.UPDATE_MESSAGE, updateMessage); + }, + async deleteMessage(io, socket, message) { + const { messageId } = message; + const messageInfo = await MessageService.getInstance().getMessage(messageId); + const { chatroomId } = messageInfo.chatroom; + await MessageService.getInstance().deleteMessage(messageId); + io.to(String(chatroomId)).emit(eventName.DELETE_MESSAGE, { messageId }); + } +}; + +export default messageHandler; diff --git a/server/src/socket/handler/reply-handler.ts b/server/src/socket/handler/reply-handler.ts new file mode 100644 index 0000000..9fc1ba0 --- /dev/null +++ b/server/src/socket/handler/reply-handler.ts @@ -0,0 +1,35 @@ +import Reply from '@model/reply'; +import ReplyService from '@service/reply-service'; +import eventName from '@constants/event-name'; + +const messageHandler = { + async createReply(io, socket, reply) { + const req = socket.request; + const { userId } = req.user; + const { messageId, content } = reply; + const replyId = await ReplyService.getInstance().createReply(userId, messageId, content); + const newReply = await ReplyService.getInstance().getReply(replyId); + const replyInfo = await ReplyService.getInstance().getReplyInfo(replyId); + const { chatroomId } = replyInfo.message.chatroom; + io.to(String(chatroomId)).emit(eventName.CREATE_REPLY, { ...newReply, chatroomId }); + }, + + async updateReply(io, socket, reply) { + const { replyId, content } = reply; + await ReplyService.getInstance().updateReply(replyId, content); + const replyInfo = await ReplyService.getInstance().getReplyInfo(replyId); + const { chatroomId } = replyInfo.message.chatroom; + io.to(String(chatroomId)).emit(eventName.UPDATE_REPLY, { replyId, content, chatroomId }); + }, + + async deleteReply(io, socket, reply) { + const { replyId } = reply; + const replyInfo = await ReplyService.getInstance().getReplyInfo(replyId); + const { messageId } = replyInfo.message; + const { chatroomId } = replyInfo.message.chatroom; + await ReplyService.getInstance().deleteReply(replyId); + io.to(String(chatroomId)).emit(eventName.DELETE_REPLY, { replyId, messageId, chatroomId }); + } +}; + +export default messageHandler; diff --git a/server/src/socket/index.ts b/server/src/socket/index.ts new file mode 100644 index 0000000..9ffce81 --- /dev/null +++ b/server/src/socket/index.ts @@ -0,0 +1,11 @@ +import connectionEvent from '@socket/event/connection-event'; +import messageEvent from '@socket/event/message-event'; +import replyEvent from '@socket/event/reply-event'; + +function socketIndex(io, socket) { + connectionEvent(io, socket); + messageEvent(io, socket); + replyEvent(io, socket); +} + +export default socketIndex; diff --git a/server/src/socket/init-socket.ts b/server/src/socket/init-socket.ts new file mode 100644 index 0000000..3d0734c --- /dev/null +++ b/server/src/socket/init-socket.ts @@ -0,0 +1,9 @@ +import jwtMiddleware from '@socket/middleware/jwt'; +import socketIndex from '@socket/index'; + +const initSocketIo = (io) => { + jwtMiddleware(io); + io.on('connection', (socket) => socketIndex(io, socket)); +}; + +export default initSocketIo; diff --git a/server/src/socket/middleware/jwt.ts b/server/src/socket/middleware/jwt.ts new file mode 100644 index 0000000..68bf06c --- /dev/null +++ b/server/src/socket/middleware/jwt.ts @@ -0,0 +1,8 @@ +import passport from 'passport'; + +const jwtMiddleware = (io) => { + const wrap = (middleware) => (socket, next) => middleware(socket.request, {}, next); + io.use(wrap(passport.authenticate('jwt', { session: false }))); +}; + +export default jwtMiddleware; diff --git a/server/src/socket/socket.ts b/server/src/socket/socket.ts new file mode 100644 index 0000000..fd7236c --- /dev/null +++ b/server/src/socket/socket.ts @@ -0,0 +1,13 @@ +import { Server } from 'socket.io'; + +const Socket = (server) => { + const io = new Server(server, { + cors: { + origin: '*', + methods: ['GET', 'POST'] + } + }); + return io; +}; + +export default Socket; diff --git a/server/tsconfig.json b/server/tsconfig.json index 406096a..32aeda1 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -23,6 +23,7 @@ "@router/*": ["src/router/*"], "@service/*": ["src/service/*"], "@config/*":["src/common/config/*"], + "@socket/*":["src/socket/*"] } }, } \ No newline at end of file