diff --git a/doc/review/meme-feed-code-review.md b/doc/review/meme-feed-code-review.md new file mode 100644 index 0000000..ea4b224 --- /dev/null +++ b/doc/review/meme-feed-code-review.md @@ -0,0 +1,16 @@ +# Meme feed code review report + +## Identified Problems: + +- The entire feed is loaded recursively on the home page. +- The components are not sufficiently broken down. +- The `index.tsx` file of the feed handles API calls, data manipulation, and display at the same time. +- All comments for each meme are loaded from the start. +- The publication time of memes and comments does not take into account the user's timezone. + +## Solutions Provided: + +- Breakdown of the feed into: `MemeListLayout` + `MemeList` (for data control) + `MemeCard` (for display). +- Added `useInView` and `useInfiniteQuery` to load `MemeCard` on scroll. +- Only the first page of comments is preloaded, the rest are loaded on scroll once the comments are opened. +- Added a temporary fix for timezones while awaiting a backend fix: `format(comment.createdAt + "Z")`. diff --git a/package-lock.json b/package-lock.json index a455380..76054f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,13 +15,19 @@ "@phosphor-icons/react": "^2.1.7", "@tanstack/react-query": "^5.51.17", "@tanstack/react-router": "^1.46.0", + "@types/js-cookie": "^3.0.6", + "axios": "^1.7.7", + "eventemitter3": "^5.0.1", "framer-motion": "^11.3.19", + "js-cookie": "^3.0.5", "jwt-decode": "^4.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-dropzone": "^14.2.3", "react-hook-form": "^7.52.1", - "timeago.js": "^4.0.2" + "react-intersection-observer": "^9.13.1", + "timeago.js": "^4.0.2", + "zod": "^3.23.8" }, "devDependencies": { "@tanstack/router-devtools": "^1.46.0", @@ -2639,208 +2645,224 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.19.1.tgz", - "integrity": "sha512-XzqSg714++M+FXhHfXpS1tDnNZNpgxxuGZWlRG/jSj+VEPmZ0yg6jV4E0AL3uyBKxO8mO3xtOsP5mQ+XLfrlww==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz", + "integrity": "sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.19.1.tgz", - "integrity": "sha512-thFUbkHteM20BGShD6P08aungq4irbIZKUNbG70LN8RkO7YztcGPiKTTGZS7Kw+x5h8hOXs0i4OaHwFxlpQN6A==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.4.tgz", + "integrity": "sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.19.1.tgz", - "integrity": "sha512-8o6eqeFZzVLia2hKPUZk4jdE3zW7LCcZr+MD18tXkgBBid3lssGVAYuox8x6YHoEPDdDa9ixTaStcmx88lio5Q==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.4.tgz", + "integrity": "sha512-xMM9ORBqu81jyMKCDP+SZDhnX2QEVQzTcC6G18KlTQEzWK8r/oNZtKuZaCcHhnsa6fEeOBionoyl5JsAbE/36Q==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.19.1.tgz", - "integrity": "sha512-4T42heKsnbjkn7ovYiAdDVRRWZLU9Kmhdt6HafZxFcUdpjlBlxj4wDrt1yFWLk7G4+E+8p2C9tcmSu0KA6auGA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.4.tgz", + "integrity": "sha512-aJJyYKQwbHuhTUrjWjxEvGnNNBCnmpHDvrb8JFDbeSH3m2XdHcxDd3jthAzvmoI8w/kSjd2y0udT+4okADsZIw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.19.1.tgz", - "integrity": "sha512-MXg1xp+e5GhZ3Vit1gGEyoC+dyQUBy2JgVQ+3hUrD9wZMkUw/ywgkpK7oZgnB6kPpGrxJ41clkPPnsknuD6M2Q==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.4.tgz", + "integrity": "sha512-j63YtCIRAzbO+gC2L9dWXRh5BFetsv0j0va0Wi9epXDgU/XUi5dJKo4USTttVyK7fGw2nPWK0PbAvyliz50SCQ==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.19.1.tgz", - "integrity": "sha512-DZNLwIY4ftPSRVkJEaxYkq7u2zel7aah57HESuNkUnz+3bZHxwkCUkrfS2IWC1sxK6F2QNIR0Qr/YXw7nkF3Pw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.4.tgz", + "integrity": "sha512-dJnWUgwWBX1YBRsuKKMOlXCzh2Wu1mlHzv20TpqEsfdZLb3WoJW2kIEsGwLkroYf24IrPAvOT/ZQ2OYMV6vlrg==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.19.1.tgz", - "integrity": "sha512-C7evongnjyxdngSDRRSQv5GvyfISizgtk9RM+z2biV5kY6S/NF/wta7K+DanmktC5DkuaJQgoKGf7KUDmA7RUw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.4.tgz", + "integrity": "sha512-AdPRoNi3NKVLolCN/Sp4F4N1d98c4SBnHMKoLuiG6RXgoZ4sllseuGioszumnPGmPM2O7qaAX/IJdeDU8f26Aw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.19.1.tgz", - "integrity": "sha512-89tFWqxfxLLHkAthAcrTs9etAoBFRduNfWdl2xUs/yLV+7XDrJ5yuXMHptNqf1Zw0UCA3cAutkAiAokYCkaPtw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.4.tgz", + "integrity": "sha512-Gl0AxBtDg8uoAn5CCqQDMqAx22Wx22pjDOjBdmG0VIWX3qUBHzYmOKh8KXHL4UpogfJ14G4wk16EQogF+v8hmA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.19.1.tgz", - "integrity": "sha512-PromGeV50sq+YfaisG8W3fd+Cl6mnOOiNv2qKKqKCpiiEke2KiKVyDqG/Mb9GWKbYMHj5a01fq/qlUR28PFhCQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.4.tgz", + "integrity": "sha512-3aVCK9xfWW1oGQpTsYJJPF6bfpWfhbRnhdlyhak2ZiyFLDaayz0EP5j9V1RVLAAxlmWKTDfS9wyRyY3hvhPoOg==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.19.1.tgz", - "integrity": "sha512-/1BmHYh+iz0cNCP0oHCuF8CSiNj0JOGf0jRlSo3L/FAyZyG2rGBuKpkZVH9YF+x58r1jgWxvm1aRg3DHrLDt6A==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.4.tgz", + "integrity": "sha512-ePYIir6VYnhgv2C5Xe9u+ico4t8sZWXschR6fMgoPUK31yQu7hTEJb7bCqivHECwIClJfKgE7zYsh1qTP3WHUA==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.19.1.tgz", - "integrity": "sha512-0cYP5rGkQWRZKy9/HtsWVStLXzCF3cCBTRI+qRL8Z+wkYlqN7zrSYm6FuY5Kd5ysS5aH0q5lVgb/WbG4jqXN1Q==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.4.tgz", + "integrity": "sha512-GqFJ9wLlbB9daxhVlrTe61vJtEY99/xB3C8e4ULVsVfflcpmR6c8UZXjtkMA6FhNONhj2eA5Tk9uAVw5orEs4Q==", "cpu": [ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.19.1.tgz", - "integrity": "sha512-XUXeI9eM8rMP8aGvii/aOOiMvTs7xlCosq9xCjcqI9+5hBxtjDpD+7Abm1ZhVIFE1J2h2VIg0t2DX/gjespC2Q==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.4.tgz", + "integrity": "sha512-87v0ol2sH9GE3cLQLNEy0K/R0pz1nvg76o8M5nhMR0+Q+BBGLnb35P0fVz4CQxHYXaAOhE8HhlkaZfsdUOlHwg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.19.1.tgz", - "integrity": "sha512-V7cBw/cKXMfEVhpSvVZhC+iGifD6U1zJ4tbibjjN+Xi3blSXaj/rJynAkCFFQfoG6VZrAiP7uGVzL440Q6Me2Q==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.4.tgz", + "integrity": "sha512-UV6FZMUgePDZrFjrNGIWzDo/vABebuXBhJEqrHxrGiU6HikPy0Z3LfdtciIttEUQfuDdCn8fqh7wiFJjCNwO+g==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.19.1.tgz", - "integrity": "sha512-88brja2vldW/76jWATlBqHEoGjJLRnP0WOEKAUbMcXaAZnemNhlAHSyj4jIwMoP2T750LE9lblvD4e2jXleZsA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.4.tgz", + "integrity": "sha512-BjI+NVVEGAXjGWYHz/vv0pBqfGoUH0IGZ0cICTn7kB9PyjrATSkX+8WkguNjWoj2qSr1im/+tTGRaY+4/PdcQw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.19.1.tgz", - "integrity": "sha512-LdxxcqRVSXi6k6JUrTah1rHuaupoeuiv38du8Mt4r4IPer3kwlTo+RuvfE8KzZ/tL6BhaPlzJ3835i6CxrFIRQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.4.tgz", + "integrity": "sha512-SiWG/1TuUdPvYmzmYnmd3IEifzR61Tragkbx9D3+R8mzQqDBz8v+BvZNDlkiTtI9T15KYZhP0ehn3Dld4n9J5g==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.19.1.tgz", - "integrity": "sha512-2bIrL28PcK3YCqD9anGxDxamxdiJAxA+l7fWIwM5o8UqNy1t3d1NdAweO2XhA0KTDJ5aH1FsuiT5+7VhtHliXg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.4.tgz", + "integrity": "sha512-j8pPKp53/lq9lMXN57S8cFz0MynJk8OWNuUnXct/9KCpKU7DgU3bYMJhwWmcqC0UU29p8Lr0/7KEVcaM6bf47Q==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -3582,6 +3604,12 @@ "pretty-format": "^29.0.0" } }, + "node_modules/@types/js-cookie": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", + "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", + "license": "MIT" + }, "node_modules/@types/lodash": { "version": "4.17.7", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz", @@ -4166,8 +4194,7 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/attr-accept": { "version": "2.2.2", @@ -4177,6 +4204,17 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/babel-dead-code-elimination": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.6.tgz", @@ -4524,7 +4562,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -4685,7 +4722,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "engines": { "node": ">=0.4.0" } @@ -5119,6 +5155,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, "node_modules/execa": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", @@ -5300,6 +5342,26 @@ "node": ">=10" } }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/foreground-child": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz", @@ -5320,7 +5382,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -6271,6 +6332,15 @@ "node": ">=8" } }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6531,10 +6601,11 @@ } }, "node_modules/micromatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", - "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, + "license": "MIT", "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" @@ -6547,7 +6618,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -6556,7 +6626,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "dependencies": { "mime-db": "1.52.0" }, @@ -6759,6 +6828,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -7006,10 +7076,11 @@ "dev": true }, "node_modules/path-to-regexp": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.2.tgz", - "integrity": "sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw==", - "dev": true + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" }, "node_modules/path-type": { "version": "4.0.0", @@ -7035,9 +7106,10 @@ } }, "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", + "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", @@ -7052,9 +7124,9 @@ } }, "node_modules/postcss": { - "version": "8.4.40", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.40.tgz", - "integrity": "sha512-YF2kKIUzAofPMpfH6hOi2cGnv/HrUlfucspc7pDyvv7kGdqXrfj8SCl/t8owkEgKEuu8ZcRjSOxFxVLqwChZ2Q==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "dev": true, "funding": [ { @@ -7070,10 +7142,11 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.1", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -7145,6 +7218,12 @@ "react-is": "^16.13.1" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", @@ -7278,6 +7357,21 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-intersection-observer": { + "version": "9.13.1", + "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.13.1.tgz", + "integrity": "sha512-tSzDaTy0qwNPLJHg8XZhlyHTgGW6drFKTtvjdL+p6um12rcnp8Z5XstE+QNBJ7c64n5o0Lj4ilUleA41bmDoMw==", + "license": "MIT", + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -7446,10 +7540,11 @@ } }, "node_modules/rollup": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.19.1.tgz", - "integrity": "sha512-K5vziVlg7hTpYfFBI+91zHBEMo6jafYXpkMlqZjg7/zhIG9iHqazBf4xz9AVdjS9BruRn280ROqLI7G3OFRIlw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.4.tgz", + "integrity": "sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==", "dev": true, + "license": "MIT", "dependencies": { "@types/estree": "1.0.5" }, @@ -7461,22 +7556,22 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.19.1", - "@rollup/rollup-android-arm64": "4.19.1", - "@rollup/rollup-darwin-arm64": "4.19.1", - "@rollup/rollup-darwin-x64": "4.19.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.19.1", - "@rollup/rollup-linux-arm-musleabihf": "4.19.1", - "@rollup/rollup-linux-arm64-gnu": "4.19.1", - "@rollup/rollup-linux-arm64-musl": "4.19.1", - "@rollup/rollup-linux-powerpc64le-gnu": "4.19.1", - "@rollup/rollup-linux-riscv64-gnu": "4.19.1", - "@rollup/rollup-linux-s390x-gnu": "4.19.1", - "@rollup/rollup-linux-x64-gnu": "4.19.1", - "@rollup/rollup-linux-x64-musl": "4.19.1", - "@rollup/rollup-win32-arm64-msvc": "4.19.1", - "@rollup/rollup-win32-ia32-msvc": "4.19.1", - "@rollup/rollup-win32-x64-msvc": "4.19.1", + "@rollup/rollup-android-arm-eabi": "4.22.4", + "@rollup/rollup-android-arm64": "4.22.4", + "@rollup/rollup-darwin-arm64": "4.22.4", + "@rollup/rollup-darwin-x64": "4.22.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.22.4", + "@rollup/rollup-linux-arm-musleabihf": "4.22.4", + "@rollup/rollup-linux-arm64-gnu": "4.22.4", + "@rollup/rollup-linux-arm64-musl": "4.22.4", + "@rollup/rollup-linux-powerpc64le-gnu": "4.22.4", + "@rollup/rollup-linux-riscv64-gnu": "4.22.4", + "@rollup/rollup-linux-s390x-gnu": "4.22.4", + "@rollup/rollup-linux-x64-gnu": "4.22.4", + "@rollup/rollup-linux-x64-musl": "4.22.4", + "@rollup/rollup-win32-arm64-msvc": "4.22.4", + "@rollup/rollup-win32-ia32-msvc": "4.22.4", + "@rollup/rollup-win32-x64-msvc": "4.22.4", "fsevents": "~2.3.2" } }, @@ -7604,10 +7699,11 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -8106,14 +8202,15 @@ } }, "node_modules/vite": { - "version": "5.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.5.tgz", - "integrity": "sha512-MdjglKR6AQXQb9JGiS7Rc2wC6uMjcm7Go/NHNO63EwiJXfuk9PgqiP/n5IDJCziMkfw9n4Ubp7lttNwz+8ZVKA==", + "version": "5.4.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.8.tgz", + "integrity": "sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==", "dev": true, + "license": "MIT", "dependencies": { "esbuild": "^0.21.3", - "postcss": "^8.4.39", - "rollup": "^4.13.0" + "postcss": "^8.4.43", + "rollup": "^4.20.0" }, "bin": { "vite": "bin/vite.js" @@ -8132,6 +8229,7 @@ "less": "*", "lightningcss": "^1.21.0", "sass": "*", + "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" @@ -8149,6 +8247,9 @@ "sass": { "optional": true }, + "sass-embedded": { + "optional": true + }, "stylus": { "optional": true }, @@ -8574,7 +8675,7 @@ "version": "3.23.8", "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", - "dev": true, + "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 48c50c1..ca5ee71 100644 --- a/package.json +++ b/package.json @@ -19,13 +19,19 @@ "@phosphor-icons/react": "^2.1.7", "@tanstack/react-query": "^5.51.17", "@tanstack/react-router": "^1.46.0", + "@types/js-cookie": "^3.0.6", + "axios": "^1.7.7", + "eventemitter3": "^5.0.1", "framer-motion": "^11.3.19", + "js-cookie": "^3.0.5", "jwt-decode": "^4.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-dropzone": "^14.2.3", "react-hook-form": "^7.52.1", - "timeago.js": "^4.0.2" + "react-intersection-observer": "^9.13.1", + "timeago.js": "^4.0.2", + "zod": "^3.23.8" }, "devDependencies": { "@tanstack/router-devtools": "^1.46.0", diff --git a/src/__tests__/routes/_authentication/index.test.tsx b/src/__tests__/routes/_authentication/index.test.tsx index 1cfde30..f2d943f 100644 --- a/src/__tests__/routes/_authentication/index.test.tsx +++ b/src/__tests__/routes/_authentication/index.test.tsx @@ -1,4 +1,4 @@ -import { screen, waitFor } from "@testing-library/react"; +import { screen, waitFor, fireEvent } from "@testing-library/react"; import { ChakraProvider } from "@chakra-ui/react"; import { QueryClientProvider, QueryClient } from "@tanstack/react-query"; import { AuthenticationContext } from "../../../contexts/authentication"; @@ -37,42 +37,77 @@ describe("routes/_authentication/index", () => { await waitFor(() => { // We check that the right author's username is displayed - expect(screen.getByTestId("meme-author-dummy_meme_id_1")).toHaveTextContent('dummy_user_1'); - + expect( + screen.getByTestId("meme-author-dummy_meme_id_1") + ).toHaveTextContent("dummy_user_1"); + + //click in comments + fireEvent.click( + screen.getByTestId("meme-comments-section-dummy_meme_id_1") + ); + // We check that the right meme's picture is displayed expect(screen.getByTestId("meme-picture-dummy_meme_id_1")).toHaveStyle({ - 'background-image': 'url("https://dummy.url/meme/1")', + "background-image": 'url("https://dummy.url/meme/1")', }); // We check that the right texts are displayed at the right positions const text1 = screen.getByTestId("meme-picture-dummy_meme_id_1-text-0"); const text2 = screen.getByTestId("meme-picture-dummy_meme_id_1-text-1"); - expect(text1).toHaveTextContent('dummy text 1'); + expect(text1).toHaveTextContent("dummy text 1"); expect(text1).toHaveStyle({ - 'top': '0px', - 'left': '0px', + top: "0px", + left: "0px", }); - expect(text2).toHaveTextContent('dummy text 2'); + expect(text2).toHaveTextContent("dummy text 2"); expect(text2).toHaveStyle({ - 'top': '100px', - 'left': '100px', + top: "100px", + left: "100px", }); // We check that the right description is displayed - expect(screen.getByTestId("meme-description-dummy_meme_id_1")).toHaveTextContent('dummy meme 1'); - + expect( + screen.getByTestId("meme-description-dummy_meme_id_1") + ).toHaveTextContent("dummy meme 1"); + // We check that the right number of comments is displayed - expect(screen.getByTestId("meme-comments-count-dummy_meme_id_1")).toHaveTextContent('3 comments'); - + expect( + screen.getByTestId("meme-comments-count-dummy_meme_id_1") + ).toHaveTextContent("3 comments"); + // We check that the right comments with the right authors are displayed - expect(screen.getByTestId("meme-comment-content-dummy_meme_id_1-dummy_comment_id_1")).toHaveTextContent('dummy comment 1'); - expect(screen.getByTestId("meme-comment-author-dummy_meme_id_1-dummy_comment_id_1")).toHaveTextContent('dummy_user_1'); + expect( + screen.getByTestId( + "meme-comment-content-dummy_meme_id_1-dummy_comment_id_1" + ) + ).toHaveTextContent("dummy comment 1"); + expect( + screen.getByTestId( + "meme-comment-author-dummy_meme_id_1-dummy_comment_id_1" + ) + ).toHaveTextContent("dummy_user_1"); + + expect( + screen.getByTestId( + "meme-comment-content-dummy_meme_id_1-dummy_comment_id_2" + ) + ).toHaveTextContent("dummy comment 2"); + expect( + screen.getByTestId( + "meme-comment-author-dummy_meme_id_1-dummy_comment_id_2" + ) + ).toHaveTextContent("dummy_user_2"); - expect(screen.getByTestId("meme-comment-content-dummy_meme_id_1-dummy_comment_id_2")).toHaveTextContent('dummy comment 2'); - expect(screen.getByTestId("meme-comment-author-dummy_meme_id_1-dummy_comment_id_2")).toHaveTextContent('dummy_user_2'); - - expect(screen.getByTestId("meme-comment-content-dummy_meme_id_1-dummy_comment_id_3")).toHaveTextContent('dummy comment 3'); - expect(screen.getByTestId("meme-comment-author-dummy_meme_id_1-dummy_comment_id_3")).toHaveTextContent('dummy_user_3'); + expect( + screen.getByTestId( + "meme-comment-content-dummy_meme_id_1-dummy_comment_id_3" + ) + ).toHaveTextContent("dummy comment 3"); + expect( + screen.getByTestId( + "meme-comment-author-dummy_meme_id_1-dummy_comment_id_3" + ) + ).toHaveTextContent("dummy_user_3"); }); }); }); diff --git a/src/__tests__/routes/login.test.tsx b/src/__tests__/routes/login.test.tsx index 5a9d17a..5a6a211 100644 --- a/src/__tests__/routes/login.test.tsx +++ b/src/__tests__/routes/login.test.tsx @@ -2,13 +2,11 @@ import { vi } from "vitest"; import { act, fireEvent, waitFor, screen } from "@testing-library/react"; import { renderWithRouter } from "../utils"; import { LoginPage } from "../../routes/login"; -import { - AuthenticationContext, - AuthenticationState, -} from "../../contexts/authentication"; +import { AuthenticationContext } from "../../contexts/authentication"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ChakraProvider } from "@chakra-ui/react"; import { ListenerFn, RouterEvents } from "@tanstack/react-router"; +import { AuthenticationState } from "../../types/authentication-state"; type RenderLoginPageParams = { authenticate?: (token: string) => void; @@ -99,7 +97,7 @@ describe("routes/login", () => { await waitFor(() => { expect( - screen.getByText(/an unknown error occured, please try again later/i), + screen.getByText(/an unknown error occured, please try again later/i) ).toBeInTheDocument(); }); }); @@ -119,7 +117,7 @@ describe("routes/login", () => { expect(onBeforeNavigateMock).toHaveBeenCalledWith( expect.objectContaining({ toLocation: expect.objectContaining({ pathname: "/" }), - }), + }) ); }); }); @@ -140,7 +138,7 @@ describe("routes/login", () => { expect(onBeforeNavigateMock).toHaveBeenCalledWith( expect.objectContaining({ toLocation: expect.objectContaining({ pathname: "/profile" }), - }), + }) ); }); }); diff --git a/src/api.ts b/src/api.ts deleted file mode 100644 index 9bb78c9..0000000 --- a/src/api.ts +++ /dev/null @@ -1,149 +0,0 @@ -const BASE_URL = import.meta.env.VITE_API_BASE_URL as string; - -export class UnauthorizedError extends Error { - constructor() { - super('Unauthorized'); - } -} - -export class NotFoundError extends Error { - constructor() { - super('Not Found'); - } -} - -function checkStatus(response: Response) { - if (response.status === 401) { - throw new UnauthorizedError(); - } - if (response.status === 404) { - throw new NotFoundError(); - } - return response; -} - -export type LoginResponse = { - jwt: string -} - -/** - * Authenticate the user with the given credentials - * @param username - * @param password - * @returns - */ -export async function login(username: string, password: string): Promise { - return await fetch(`${BASE_URL}/authentication/login`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ username, password }), - }).then(res => checkStatus(res).json()) -} - -export type GetUserByIdResponse = { - id: string; - username: string; - pictureUrl: string; -} - -/** - * Get a user by their id - * @param token - * @param id - * @returns - */ -export async function getUserById(token: string, id: string): Promise { - return await fetch(`${BASE_URL}/users/${id}`, { - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - } - }).then(res => checkStatus(res).json()) -} - -export type GetMemesResponse = { - total: number; - pageSize: number; - results: { - id: string; - authorId: string; - pictureUrl: string; - description: string; - commentsCount: string; - texts: { - content: string; - x: number; - y: number; - }[]; - createdAt: string; - }[] -} - -/** - * Get the list of memes for a given page - * @param token - * @param page - * @returns - */ -export async function getMemes(token: string, page: number): Promise { - return await fetch(`${BASE_URL}/memes?page=${page}`, { - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - } - }).then(res => checkStatus(res).json()) -} - -export type GetMemeCommentsResponse = { - total: number; - pageSize: number; - results: { - id: string; - authorId: string; - memeId: string; - content: string; - createdAt: string; - }[] -} - -/** - * Get comments for a meme - * @param token - * @param memeId - * @returns - */ -export async function getMemeComments(token: string, memeId: string, page: number): Promise { - return await fetch(`${BASE_URL}/memes/${memeId}/comments?page=${page}`, { - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - } - }).then(res => checkStatus(res).json()) -} - -export type CreateCommentResponse = { - id: string; - content: string; - createdAt: string; - authorId: string; - memeId: string; -} - -/** - * Create a comment for a meme - * @param token - * @param memeId - * @param content - */ -export async function createMemeComment(token: string, memeId: string, content: string): Promise { - return await fetch(`${BASE_URL}/memes/${memeId}/comments`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - }, - body: JSON.stringify({ content }), - }).then(res => checkStatus(res).json()); -} \ No newline at end of file diff --git a/src/components/comment-form.tsx b/src/components/comment-form.tsx new file mode 100644 index 0000000..0a04d6b --- /dev/null +++ b/src/components/comment-form.tsx @@ -0,0 +1,68 @@ +import { Avatar, Box, Flex, Input, Text } from "@chakra-ui/react"; +import { SubmitHandler, useForm } from "react-hook-form"; +import { useSubmitComment } from "../hooks/use-submit-comment"; +import { useMyProfile } from "../hooks/use-my-profile"; + +type CommentFormProps = { + memeId: string; +}; + +type Inputs = { + content: string; +}; + +export const CommentForm: React.FC = ({ memeId }) => { + const { + register, + handleSubmit, + resetField, + setError, + formState: { errors }, + } = useForm(); + + const { mutate } = useSubmitComment(memeId); + + const user = useMyProfile(); + + const onSubmit: SubmitHandler = async (data) => { + const trimed = data.content.trim(); + if (trimed.length === 0) { + return; + } + try { + mutate(data); + resetField("content"); + } catch (e) { + setError("content", { + type: "custom", + message: "Oopps! Something went wrong. Try again.", + }); + } + }; + + return ( + +
+ + + + + {errors.content && ( + + {errors.content.message} + + )} +
+
+ ); +}; diff --git a/src/components/comment-list.tsx b/src/components/comment-list.tsx new file mode 100644 index 0000000..01b1047 --- /dev/null +++ b/src/components/comment-list.tsx @@ -0,0 +1,45 @@ +import { VStack } from "@chakra-ui/react"; +import { useCommentList } from "../hooks/use-comment-list"; +import { MemeCommentItem } from "./meme-comment-item"; +import { useInView } from "react-intersection-observer"; +import { useEffect } from "react"; +import { useScrollRef } from "../contexts/scroll-ref"; + +type CommentListProps = { + memeId: string; +}; + +export const CommentList: React.FC = ({ memeId }) => { + const { + data: comments, + fetchNextPage, + hasNextPage, + isFetching, + } = useCommentList(memeId); + + const scrollRef = useScrollRef(); + const { ref, inView } = useInView({ + rootMargin: "200px", + root: scrollRef.ref?.current, + }); + + useEffect(() => { + fetchNextPage(); + }, [inView, fetchNextPage]); + return ( + + {comments?.pages.map((page) => { + return page.results.map((comment) => { + return ( + + ); + }); + })} + {hasNextPage && !isFetching &&
} + + ); +}; diff --git a/src/components/meme-author.tsx b/src/components/meme-author.tsx new file mode 100644 index 0000000..5b4e9ba --- /dev/null +++ b/src/components/meme-author.tsx @@ -0,0 +1,33 @@ +import { Avatar, Flex, Text } from "@chakra-ui/react"; +import { useUser } from "../hooks/use-user"; + +type MemeAuthorProps = { + authorId: string; + nameTestId?: string; +}; + +export const MemeAuthor: React.FC = ({ + authorId, + nameTestId, +}) => { + const { data: author } = useUser(authorId); + + if (!author) { + return null; + } + + return ( + + + + {author.username} + + + ); +}; diff --git a/src/components/meme-card-comments.tsx b/src/components/meme-card-comments.tsx new file mode 100644 index 0000000..29296b6 --- /dev/null +++ b/src/components/meme-card-comments.tsx @@ -0,0 +1,59 @@ +import { + Box, + Collapse, + Flex, + Icon, + LinkBox, + LinkOverlay, + Text, +} from "@chakra-ui/react"; +import { CaretDown, CaretUp, Chat } from "@phosphor-icons/react"; +import { useCallback } from "react"; +import { CommentList } from "./comment-list"; +import { CommentForm } from "./comment-form"; + +type MemeCardCommentsProps = { + memeId: string; + commentCount: number; + opened: boolean; + onOpen: (memeId: string) => void; +}; + +export const MemeCardComments: React.FC = ({ + memeId, + commentCount, + opened, + onOpen, +}) => { + const handleClickOpen = useCallback(() => { + onOpen(memeId); + }, [memeId, onOpen]); + + return ( + <> + + + + + + {commentCount > 1 + ? `${commentCount} comments` + : `${commentCount} comment`} + + + + + + + + + + + + + ); +}; diff --git a/src/components/meme-card.tsx b/src/components/meme-card.tsx new file mode 100644 index 0000000..1805f5a --- /dev/null +++ b/src/components/meme-card.tsx @@ -0,0 +1,158 @@ +import { Box, Flex, Text, VStack } from "@chakra-ui/react"; + +import { MemePicture } from "./meme-picture"; +import { format } from "timeago.js"; +import { MemeAuthor } from "./meme-author"; +import { MemeCardComments } from "./meme-card-comments"; +import { Meme } from "../services/api"; +import { TimeAgo } from "./time-ago"; + +type MemeCardrops = { + meme: Meme; + commentsOpened: boolean; + onOpenComments: (memeId: string) => void; +}; +//TODO: fix timezone from backend +export const MemeCard: React.FC = ({ + meme, + commentsOpened, + onOpenComments, +}) => { + return ( + + + + + + + + + + + Description:{" "} + + + + {meme.description} + + + + + + + {/* + + + + setOpenedCommentSection( + openedCommentSection === meme.id ? null : meme.id + ) + } + > + + {meme.commentsCount} comments + + + + + + + */} + + {/* + +
{ + event.preventDefault(); + if (commentContent[meme.id]) { + mutate({ + memeId: meme.id, + content: commentContent[meme.id], + }); + } + }} + > + + + { + setCommentContent({ + ...commentContent, + [meme.id]: event.target.value, + }); + }} + value={commentContent[meme.id]} + /> + +
+
+ + {meme.comments.map((comment) => ( + + + + + + + {comment.author.username} + + + + {format(comment.createdAt)} + + + + {comment.content} + + + + ))} + +
*/} +
+ ); +}; diff --git a/src/components/meme-comment-item.tsx b/src/components/meme-comment-item.tsx new file mode 100644 index 0000000..20c146d --- /dev/null +++ b/src/components/meme-comment-item.tsx @@ -0,0 +1,49 @@ +import { Avatar, Box, Flex, Text } from "@chakra-ui/react"; +import { format } from "timeago.js"; +import { useUser } from "../hooks/use-user"; +import { MemeComment } from "../services/api"; + +type MemeCommentProps = { + comment: MemeComment; + memeId: string; +}; + +export const MemeCommentItem: React.FC = ({ + comment, + memeId, +}: MemeCommentProps) => { + const { data: author } = useUser(comment.authorId); + //TODO:fix timezone from backend + return ( + + + + + + + {author?.username} + + + + {format(comment.createdAt + "Z")} + + + + {comment.content} + + + + ); +}; diff --git a/src/components/meme-list.tsx b/src/components/meme-list.tsx new file mode 100644 index 0000000..588ebbd --- /dev/null +++ b/src/components/meme-list.tsx @@ -0,0 +1,53 @@ +import { useCallback, useEffect, useState } from "react"; +import { useFeed } from "../hooks/use-feed"; +import { MemeCard } from "./meme-card"; +import { useScrollRef } from "../contexts/scroll-ref"; +import { useInView } from "react-intersection-observer"; + +export const MemeList: React.FC = () => { + const { data: feed, fetchNextPage, hasNextPage, isFetching } = useFeed(); + + const [openedCommentsMemeId, setOpenedCommentsMemeId] = useState< + string | null + >(null); + + const handleOpenComments = useCallback((memeId: string) => { + setOpenedCommentsMemeId((prev) => { + return prev === memeId ? null : memeId; + }); + }, []); + + const scrollRef = useScrollRef(); + + const { ref, inView } = useInView({ + root: scrollRef.ref?.current, + rootMargin: "400px 0px", + skip: isFetching || !hasNextPage, + }); + + useEffect(() => { + if (inView) { + fetchNextPage(); + } + }, [inView, fetchNextPage]); + + return ( + <> + {feed?.pages.map((page) => { + return page.results.map((meme) => { + return ( + + ); + }); + })} +
+ hello +
+ + ); +}; diff --git a/src/components/meme-picture.tsx b/src/components/meme-picture.tsx index 6e99619..20135ce 100644 --- a/src/components/meme-picture.tsx +++ b/src/components/meme-picture.tsx @@ -18,7 +18,7 @@ const REF_FONT_SIZE = 36; export const MemePicture: React.FC = ({ pictureUrl, texts: rawTexts, - dataTestId = '', + dataTestId = "", }) => { const containerRef = useRef(null); const dimensions = useDimensions(containerRef, true); diff --git a/src/components/time-ago.tsx b/src/components/time-ago.tsx new file mode 100644 index 0000000..55bc76f --- /dev/null +++ b/src/components/time-ago.tsx @@ -0,0 +1,22 @@ +import { useEffect, useState } from "react"; +import { format } from "timeago.js"; + +type TimeAgoProps = { + date: string; +}; + +export const TimeAgo: React.FC = ({ date }) => { + const [formatedDate, setFormatedDate] = useState(format(date + "Z")); + + useEffect(() => { + const interval = setInterval(() => { + setFormatedDate(format(date + "Z")); + }, 60000); + + return () => { + clearInterval(interval); + }; + }, [date]); + + return <>{formatedDate}; +}; diff --git a/src/components/user-dropdown.tsx b/src/components/user-dropdown.tsx index f4a7cb6..b9f98a6 100644 --- a/src/components/user-dropdown.tsx +++ b/src/components/user-dropdown.tsx @@ -11,15 +11,16 @@ import { import { useQuery } from "@tanstack/react-query"; import { CaretDown, CaretUp, SignOut } from "@phosphor-icons/react"; import { useAuthentication } from "../contexts/authentication"; -import { getUserById } from "../api"; +import { getUserById } from "../services/api"; export const UserDropdown: React.FC = () => { + console.log("render drop down"); const { state, signout } = useAuthentication(); const { data: user, isLoading } = useQuery({ queryKey: ["user", state.isAuthenticated ? state.userId : "anon"], queryFn: () => { if (state.isAuthenticated) { - return getUserById(state.token, state.userId); + return getUserById(state.userId); } return null; }, @@ -43,14 +44,19 @@ export const UserDropdown: React.FC = () => { src={user?.pictureUrl} border="1px solid white" /> - - {user?.username} - - + {user?.username} + - } onClick={signout}>Sign Out + } onClick={signout}> + Sign Out + )} diff --git a/src/contexts/authentication.tsx b/src/contexts/authentication.tsx index 34ebf26..4982868 100644 --- a/src/contexts/authentication.tsx +++ b/src/contexts/authentication.tsx @@ -1,22 +1,19 @@ -import { jwtDecode } from "jwt-decode"; import { createContext, PropsWithChildren, useCallback, useContext, + useEffect, useMemo, useState, } from "react"; - -export type AuthenticationState = - | { - isAuthenticated: true; - token: string; - userId: string; - } - | { - isAuthenticated: false; - }; +import { + authEventEmitter, + getAuthState, + logout, + setToken, +} from "../services/api"; +import { AuthenticationState } from "../types/authentication-state"; export type Authentication = { state: AuthenticationState; @@ -25,36 +22,49 @@ export type Authentication = { }; export const AuthenticationContext = createContext( - undefined, + undefined ); export const AuthenticationProvider: React.FC = ({ children, }) => { - const [state, setState] = useState({ - isAuthenticated: false, - }); + const [state, setState] = useState(getAuthState()); - const authenticate = useCallback( - (token: string) => { - setState({ - isAuthenticated: true, - token, - userId: jwtDecode<{ id: string }>(token).id, - }); - }, - [setState], - ); + useEffect(() => { + const login = () => { + console.log("handle login event"); + setState(getAuthState()); + }; + + const logout = () => { + console.log("handle logout event"); + setState(getAuthState()); + }; + + authEventEmitter.on("login", login); + authEventEmitter.on("logout", logout); + return () => { + console.log("unmount"); + authEventEmitter.off("login", login); + authEventEmitter.off("logout", logout); + }; + }, []); + + const authenticate = useCallback((jwt: string) => { + setToken(jwt); + }, []); const signout = useCallback(() => { - setState({ isAuthenticated: false }); - }, [setState]); + logout(); + }, []); const contextValue = useMemo( () => ({ state, authenticate, signout }), - [state, authenticate, signout], + [state, authenticate, signout] ); + console.log("rerender auth context"); + return ( {children} @@ -66,7 +76,7 @@ export function useAuthentication() { const context = useContext(AuthenticationContext); if (!context) { throw new Error( - "useAuthentication must be used within an AuthenticationProvider", + "useAuthentication must be used within an AuthenticationProvider" ); } return context; diff --git a/src/contexts/scroll-ref.tsx b/src/contexts/scroll-ref.tsx new file mode 100644 index 0000000..228d9d5 --- /dev/null +++ b/src/contexts/scroll-ref.tsx @@ -0,0 +1,35 @@ +import { Flex, FlexProps } from "@chakra-ui/react"; +import { createContext, useRef, useMemo, useContext } from "react"; + +type ScrollRefState = { + ref: React.RefObject | null; +}; + +const ScrollRefContext = createContext(undefined); + +// eslint-disable-next-line react-refresh/only-export-components +export function useScrollRef() { + const context = useContext(ScrollRefContext); + if (!context) { + throw new Error( + "useAuthentication must be used within an AuthenticationProvider" + ); + } + return context; +} + +export const ScrollRefProvider: React.FC< + React.PropsWithChildren +> = ({ children, ...props }) => { + const ref = useRef(null); + + const value = useMemo(() => ({ ref }), [ref]); + + return ( + + + {children} + + + ); +}; diff --git a/src/hooks/use-comment-list.ts b/src/hooks/use-comment-list.ts new file mode 100644 index 0000000..ad09f7b --- /dev/null +++ b/src/hooks/use-comment-list.ts @@ -0,0 +1,12 @@ +import { useInfiniteQuery } from "@tanstack/react-query"; +import { getNextPageParam } from "../utils/get-next-page-param"; +import { getMemeComments } from "../services/api"; + +export function useCommentList(memeId: string) { + return useInfiniteQuery({ + queryKey: ["comments", memeId], + initialPageParam: 1, + queryFn: ({ pageParam }) => getMemeComments(memeId, pageParam), + getNextPageParam, + }); +} diff --git a/src/hooks/use-feed.ts b/src/hooks/use-feed.ts new file mode 100644 index 0000000..bb639ea --- /dev/null +++ b/src/hooks/use-feed.ts @@ -0,0 +1,12 @@ +import { useInfiniteQuery } from "@tanstack/react-query"; +import { getNextPageParam } from "../utils/get-next-page-param"; +import { getMemes } from "../services/api"; + +export function useFeed() { + return useInfiniteQuery({ + queryKey: ["memes"], + initialPageParam: 1, + queryFn: ({ pageParam }) => getMemes(pageParam), + getNextPageParam, + }); +} diff --git a/src/hooks/use-my-profile.ts b/src/hooks/use-my-profile.ts new file mode 100644 index 0000000..b58bcbc --- /dev/null +++ b/src/hooks/use-my-profile.ts @@ -0,0 +1,8 @@ +import { useAuthentication } from "../contexts/authentication"; +import { useUser } from "./use-user"; + +export function useMyProfile() { + const { state } = useAuthentication(); + const { data } = useUser(state.isAuthenticated ? state.userId : "anon"); + return data; +} diff --git a/src/hooks/use-submit-comment.ts b/src/hooks/use-submit-comment.ts new file mode 100644 index 0000000..682e6aa --- /dev/null +++ b/src/hooks/use-submit-comment.ts @@ -0,0 +1,37 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { createMemeComment, GetMemePageResponse } from "../services/api"; + +type InfiniteData = { + pages: GetMemePageResponse[]; + pageParams: unknown; +}; + +function incrementeCommentCount(data: InfiniteData, memeId: string) { + return { + ...data, + pages: data.pages.map((page) => ({ + ...page, + results: page.results.map((meme) => + meme.id === memeId + ? { ...meme, commentsCount: meme.commentsCount + 1 } + : meme + ), + })), + }; +} + +export function useSubmitComment(memeId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data: { content: string }) => { + await createMemeComment(memeId, data.content); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["comments", memeId] }); + queryClient.setQueryData(["memes"], (data) => { + if (!data) return data; + return incrementeCommentCount(data, memeId); + }); + }, + }); +} diff --git a/src/hooks/use-submit-meme.ts b/src/hooks/use-submit-meme.ts new file mode 100644 index 0000000..c0eb33b --- /dev/null +++ b/src/hooks/use-submit-meme.ts @@ -0,0 +1,10 @@ +import { useMutation } from "@tanstack/react-query"; +import { createMeme, CreateMemeParam } from "../services/api"; + +export function useSubmitMeme() { + return useMutation({ + mutationFn: async (data: CreateMemeParam) => { + return await createMeme(data); + }, + }); +} diff --git a/src/hooks/use-user.ts b/src/hooks/use-user.ts new file mode 100644 index 0000000..f8cc46e --- /dev/null +++ b/src/hooks/use-user.ts @@ -0,0 +1,9 @@ +import { useQuery } from "@tanstack/react-query"; +import { getUserById } from "../services/api"; + +export function useUser(userId: string) { + return useQuery({ + queryKey: ["user", userId], + queryFn: () => getUserById(userId), + }); +} diff --git a/src/layouts/meme-list-layout.tsx b/src/layouts/meme-list-layout.tsx new file mode 100644 index 0000000..4546098 --- /dev/null +++ b/src/layouts/meme-list-layout.tsx @@ -0,0 +1,24 @@ +import { StackDivider, VStack } from "@chakra-ui/react"; +import { ScrollRefProvider } from "../contexts/scroll-ref"; + +export const MemeListLayout: React.FC = ({ + children, +}) => { + return ( + + } + > + {children} + + + ); +}; diff --git a/src/oldindex.ts b/src/oldindex.ts new file mode 100644 index 0000000..4d11c5b --- /dev/null +++ b/src/oldindex.ts @@ -0,0 +1,263 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; +import { + Avatar, + Box, + Collapse, + Flex, + Icon, + LinkBox, + LinkOverlay, + StackDivider, + Text, + Input, + VStack, +} from "@chakra-ui/react"; +import { CaretDown, CaretUp, Chat } from "@phosphor-icons/react"; +import { format } from "timeago.js"; +import { + createMemeComment, + getMemeComments, + GetMemeCommentsResponse, + getMemes, + GetMemesResponse, + getUserById, + GetUserByIdResponse, +} from "../../api"; +import { useAuthToken } from "../../contexts/authentication"; +import { Loader } from "../../components/loader"; +import { MemePicture } from "../../components/meme-picture"; +import { useState } from "react"; +import { jwtDecode } from "jwt-decode"; + +export const MemeFeedPage: React.FC = () => { + const token = useAuthToken(); + const { isLoading, data: memes } = useQuery({ + queryKey: ["memes"], + queryFn: async () => { + const memes: GetMemesResponse["results"] = []; + const firstPage = await getMemes(token, 1); + memes.push(...firstPage.results); + const remainingPages = + Math.ceil(firstPage.total / firstPage.pageSize) - 1; + // for (let i = 0; i < remainingPages; i++) { + // const page = await getMemes(token, i + 2); + // memes.push(...page.results); + // } + const memesWithAuthorAndComments = []; + for (let meme of memes) { + const author = await getUserById(token, meme.authorId); + const comments: GetMemeCommentsResponse["results"] = []; + const firstPage = await getMemeComments(token, meme.id, 1); + comments.push(...firstPage.results); + const remainingCommentPages = + Math.ceil(firstPage.total / firstPage.pageSize) - 1; + for (let i = 0; i < remainingCommentPages; i++) { + const page = await getMemeComments(token, meme.id, i + 2); + comments.push(...page.results); + } + const commentsWithAuthor: (GetMemeCommentsResponse["results"][0] & { + author: GetUserByIdResponse; + })[] = []; + for (let comment of comments) { + const author = await getUserById(token, comment.authorId); + commentsWithAuthor.push({ ...comment, author }); + } + memesWithAuthorAndComments.push({ + ...meme, + author, + comments: commentsWithAuthor, + }); + } + return memesWithAuthorAndComments; + }, + }); + const { data: user } = useQuery({ + queryKey: ["user"], + queryFn: async () => { + console.log("fetch user"); + return await getUserById(token, jwtDecode<{ id: string }>(token).id); + }, + }); + const [openedCommentSection, setOpenedCommentSection] = useState< + string | null + >(null); + const [commentContent, setCommentContent] = useState<{ + [key: string]: string; + }>({}); + const { mutate } = useMutation({ + mutationFn: async (data: { memeId: string; content: string }) => { + await createMemeComment(token, data.memeId, data.content); + }, + }); + if (isLoading) { + return ; + } + return ( + + } + > + {memes?.map((meme) => { + return ( + + + + + + {meme.author.username} + + + + {format(meme.createdAt)} + + + + + + Description:{" "} + + + + {meme.description} + + + + + + + + setOpenedCommentSection( + openedCommentSection === meme.id ? null : meme.id + ) + } + > + + {meme.commentsCount} comments + + + + + + + + + +
{ + event.preventDefault(); + if (commentContent[meme.id]) { + mutate({ + memeId: meme.id, + content: commentContent[meme.id], + }); + } + }} + > + + + { + setCommentContent({ + ...commentContent, + [meme.id]: event.target.value, + }); + }} + value={commentContent[meme.id]} + /> + +
+
+ + {meme.comments.map((comment) => ( + + + + + + + {comment.author.username} + + + + {format(comment.createdAt)} + + + + {comment.content} + + + + ))} + +
+
+ ); + })} +
+
+ ); +}; + +export const Route = createFileRoute("/_authentication/")({ + component: MemeFeedPage, +}); diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index ef276aa..9f93c9e 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react-hooks/rules-of-hooks */ import { Flex, Heading, @@ -11,12 +12,10 @@ import { Link, Outlet, } from "@tanstack/react-router"; -import { - AuthenticationState, - useAuthentication, -} from "../contexts/authentication"; +import { useAuthentication } from "../contexts/authentication"; import { UserDropdown } from "../components/user-dropdown"; import { Plus } from "@phosphor-icons/react"; +import { AuthenticationState } from "../types/authentication-state"; type RouterContext = { authState: AuthenticationState; @@ -25,6 +24,7 @@ type RouterContext = { export const Route = createRootRouteWithContext()({ component: () => { const { state } = useAuthentication(); + return ( {/* Header */} diff --git a/src/routes/_authentication.tsx b/src/routes/_authentication.tsx index 771e619..22f1fbf 100644 --- a/src/routes/_authentication.tsx +++ b/src/routes/_authentication.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react-hooks/rules-of-hooks */ import { createFileRoute, Navigate, diff --git a/src/routes/_authentication/create.tsx b/src/routes/_authentication/create.tsx index 8e0a7c5..1f1e8ae 100644 --- a/src/routes/_authentication/create.tsx +++ b/src/routes/_authentication/create.tsx @@ -10,57 +10,100 @@ import { Textarea, VStack, } from "@chakra-ui/react"; -import { createFileRoute, Link } from "@tanstack/react-router"; +import { createFileRoute, Link, Navigate } from "@tanstack/react-router"; import { MemeEditor } from "../../components/meme-editor"; -import { useMemo, useState } from "react"; -import { MemePictureProps } from "../../components/meme-picture"; +import { useEffect, useMemo } from "react"; import { Plus, Trash } from "@phosphor-icons/react"; +import { + SubmitHandler, + useFieldArray, + useForm, + useWatch, +} from "react-hook-form"; +import { Text } from "../../services/api"; +import { useSubmitMeme } from "../../hooks/use-submit-meme"; export const Route = createFileRoute("/_authentication/create")({ component: CreateMemePage, }); -type Picture = { - url: string; - file: File; +type CreateForm = { + picture: File; + description: string; + texts: Text[]; }; function CreateMemePage() { - const [picture, setPicture] = useState(null); - const [texts, setTexts] = useState([]); + const { + control, + register, + handleSubmit, + setValue, + watch: watchForm, + } = useForm({ + defaultValues: { + texts: [], + description: "", + picture: undefined, + }, + }); + const { fields, append, remove } = useFieldArray({ + control, + name: "texts", + shouldUnregister: true, + }); + + const { mutate, data } = useSubmitMeme(); + + useEffect(() => { + console.log("mutate data", data); + }, [data]); + + const onSubmit: SubmitHandler = async (data) => { + const result = mutate(data); + console.log("result", result); + }; const handleDrop = (file: File) => { - setPicture({ - url: URL.createObjectURL(file), - file, - }); + setValue("picture", file); }; + const watchPicture = watchForm("picture"); + + const pictureUrl = useMemo(() => { + if (!watchPicture) { + return; + } + return URL.createObjectURL(watchPicture); + }, [watchPicture]); + const handleAddCaptionButtonClick = () => { - setTexts([ - ...texts, - { - content: `New caption ${texts.length + 1}`, - x: Math.random() * 400, - y: Math.random() * 225, - }, - ]); + append({ + content: `New caption ${fields.length + 1}`, + x: Math.random() * 400, + y: Math.random() * 225, + }); }; - const handleDeleteCaptionButtonClick = (index: number) => { - setTexts(texts.filter((_, i) => i !== index)); - }; + //TODO: as Text[] should be removed + const watchedTexts = useWatch({ + control, + name: "texts", + }) as Text[]; const memePicture = useMemo(() => { - if (!picture) { + if (!pictureUrl) { return undefined; } - return { - pictureUrl: picture.url, - texts, + pictureUrl: pictureUrl || "", + texts: watchedTexts || [], }; - }, [picture, texts]); + }, [pictureUrl, watchedTexts]); + + if (data) { + return ; + } return ( @@ -76,7 +119,10 @@ function CreateMemePage() { Describe your meme -