diff --git a/package-lock.json b/package-lock.json index 1c048e04..de732482 100644 --- a/package-lock.json +++ b/package-lock.json @@ -97,6 +97,7 @@ "husky": "8.0.3", "hygen": "6.2.11", "i18next-http-backend": "2.2.0", + "javascript-time-ago": "2.5.9", "jest": "29.7.0", "js-cookie": "3.0.5", "lint-staged": "14.0.1", @@ -3808,9 +3809,9 @@ "dev": true }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.19", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.19.tgz", - "integrity": "sha512-kf37QtfW+Hwx/buWGMPcR60iF9ziHa6r/CZJIHbmcm4+0qrXiVdxegAH0F6yddEVQ7zdkjcGCgCzUu+BcbhQxw==", + "version": "0.3.20", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", + "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", "dev": true, "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -4494,14 +4495,14 @@ } }, "node_modules/@peculiar/asn1-schema": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.6.tgz", - "integrity": "sha512-izNRxPoaeJeg/AyH8hER6s+H7p4itk+03QCa4sbxI3lNdseQYCuxzgsuNK8bTXChtLTjpJz6NmXKA73qLa3rCA==", + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.8.tgz", + "integrity": "sha512-ULB1XqHKx1WBU/tTFIA+uARuRoBVZ4pNdOA878RDrRbBfBGcSzi5HBkdScC6ZbHn8z7L8gmKCgPC1LHRrP46tA==", "dev": true, "dependencies": { "asn1js": "^3.0.5", - "pvtsutils": "^1.3.2", - "tslib": "^2.4.0" + "pvtsutils": "^1.3.5", + "tslib": "^2.6.2" } }, "node_modules/@peculiar/json-schema": { @@ -5332,9 +5333,9 @@ } }, "node_modules/@types/babel__core": { - "version": "7.20.2", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.2.tgz", - "integrity": "sha512-pNpr1T1xLUc2l3xJKuPtsEky3ybxN3m4fJkknfIpTCTfIZCDW57oAg+EfCgIIp2rvCe0Wn++/FfodDS4YXxBwA==", + "version": "7.20.3", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.3.tgz", + "integrity": "sha512-54fjTSeSHwfan8AyHWrKbfBWiEUrNTZsUwPTDSNaaP1QDQIZbeNUg3a59E9D+375MzUw/x1vx2/0F5LBz+AeYA==", "dev": true, "dependencies": { "@babel/parser": "^7.20.7", @@ -5345,18 +5346,18 @@ } }, "node_modules/@types/babel__generator": { - "version": "7.6.5", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.5.tgz", - "integrity": "sha512-h9yIuWbJKdOPLJTbmSpPzkF67e659PbQDba7ifWm5BJ8xTv+sDmS7rFmywkWOvXedGTivCdeGSIIX8WLcRTz8w==", + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.6.tgz", + "integrity": "sha512-66BXMKb/sUWbMdBNdMvajU7i/44RkrA3z/Yt1c7R5xejt8qh84iU54yUWCtm0QwGJlDcf/gg4zd/x4mpLAlb/w==", "dev": true, "dependencies": { "@babel/types": "^7.0.0" } }, "node_modules/@types/babel__template": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.2.tgz", - "integrity": "sha512-/AVzPICMhMOMYoSx9MoKpGDKdBRsIXMNByh1PXSZoa+v6ZoLa8xxtsT/uLQ/NJm0XVAWl/BvId4MlDeXJaeIZQ==", + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.3.tgz", + "integrity": "sha512-ciwyCLeuRfxboZ4isgdNZi/tkt06m8Tw6uGbBSBgWrnnZGNXiEyM27xc/PjXGQLqlZ6ylbgHMnm7ccF9tCkOeQ==", "dev": true, "dependencies": { "@babel/parser": "^7.1.0", @@ -5364,18 +5365,18 @@ } }, "node_modules/@types/babel__traverse": { - "version": "7.20.2", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.2.tgz", - "integrity": "sha512-ojlGK1Hsfce93J0+kn3H5R73elidKUaZonirN33GSmgTUMpzI/MIFfSpF3haANe3G1bEBS9/9/QEqwTzwqFsKw==", + "version": "7.20.3", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.3.tgz", + "integrity": "sha512-Lsh766rGEFbaxMIDH7Qa+Yha8cMVI3qAK6CHt3OR0YfxOIn5Z54iHiyDRycHrBqeIiqGa20Kpsv1cavfBKkRSw==", "dev": true, "dependencies": { "@babel/types": "^7.20.7" } }, "node_modules/@types/eslint": { - "version": "8.44.4", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.44.4.tgz", - "integrity": "sha512-lOzjyfY/D9QR4hY9oblZ76B90MYTB3RrQ4z2vBIJKj9ROCRqdkYl2gSUx1x1a4IWPjKJZLL4Aw1Zfay7eMnmnA==", + "version": "8.44.6", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.44.6.tgz", + "integrity": "sha512-P6bY56TVmX8y9J87jHNgQh43h6VVU+6H7oN7hgvivV81K2XY8qJZ5vqPy/HdUoVIelii2kChYVzQanlswPWVFw==", "dev": true, "dependencies": { "@types/estree": "*", @@ -5383,39 +5384,39 @@ } }, "node_modules/@types/estree": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.2.tgz", - "integrity": "sha512-VeiPZ9MMwXjO32/Xu7+OwflfmeoRwkE/qzndw42gGtgJwZopBnzy2gD//NN1+go1mADzkDcqf/KnFRSjTJ8xJA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.3.tgz", + "integrity": "sha512-CS2rOaoQ/eAgAfcTfq6amKG7bsN+EMcgGY4FAFQdvSj2y1ixvOZTUA9mOtCai7E1SYu283XNw7urKK30nP3wkQ==", "dev": true }, "node_modules/@types/graceful-fs": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.7.tgz", - "integrity": "sha512-MhzcwU8aUygZroVwL2jeYk6JisJrPl/oov/gsgGCue9mkgl9wjGbzReYQClxiUgFDnib9FuHqTndccKeZKxTRw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.8.tgz", + "integrity": "sha512-NhRH7YzWq8WiNKVavKPBmtLYZHxNY19Hh+az28O/phfp68CF45pMFud+ZzJ8ewnxnC5smIdF3dqFeiSUQ5I+pw==", "dev": true, "dependencies": { "@types/node": "*" } }, "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", - "integrity": "sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.5.tgz", + "integrity": "sha512-zONci81DZYCZjiLe0r6equvZut0b+dBRPBN5kBDjsONnutYNtJMoWQ9uR2RkL1gLG9NMTzvf+29e5RFfPbeKhQ==", "dev": true }, "node_modules/@types/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-gPQuzaPR5h/djlAv2apEG1HVOyj1IUs7GpfMZixU0/0KXT3pm64ylHuMUI1/Akh+sq/iikxg6Z2j+fcMDXaaTQ==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.2.tgz", + "integrity": "sha512-8toY6FgdltSdONav1XtUHl4LN1yTmLza+EuDazb/fEmRNCwjyqNVIQWs2IfC74IqjHkREs/nQ2FWq5kZU9IC0w==", "dev": true, "dependencies": { "@types/istanbul-lib-coverage": "*" } }, "node_modules/@types/istanbul-reports": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.2.tgz", - "integrity": "sha512-kv43F9eb3Lhj+lr/Hn6OcLCs/sSM8bt+fIaP11rCYngfV6NVjzWXJ17owQtDQTL9tQ8WSLUrGsSJ6rJz0F1w1A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.3.tgz", + "integrity": "sha512-1nESsePMBlf0RPRffLZi5ujYh7IH1BWL4y9pr+Bn3cJBdxz+RTP8bUFljLz9HvzhhOSWKdyBZ4DIivdL6rvgZg==", "dev": true, "dependencies": { "@types/istanbul-lib-report": "*" @@ -5428,21 +5429,21 @@ "dev": true }, "node_modules/@types/js-yaml": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.7.tgz", - "integrity": "sha512-RJZP9WAMMr1514KbdSXkLRrKvYQacjr1+HWnY8pui/uBTBoSgD9ZGR17u/d4nb9NpERp0FkdLBe7hq8NIPBgkg==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.8.tgz", + "integrity": "sha512-m6jnPk1VhlYRiLFm3f8X9Uep761f+CK8mHyS65LutH2OhmBF0BeMEjHgg05usH8PLZMWWc/BUR9RPmkvpWnyRA==", "dev": true }, "node_modules/@types/json-schema": { - "version": "7.0.13", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.13.tgz", - "integrity": "sha512-RbSSoHliUbnXj3ny0CNFOoxrIDV6SUGyStHsvDqosw6CkdPV8TtWGlfecuK4ToyMEAql6pzNxgCFKanovUzlgQ==", + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.14.tgz", + "integrity": "sha512-U3PUjAudAdJBeC2pgN8uTIKgxrb4nlDF3SF0++EldXQvQBGkpFZMSnwQiIoDU77tv45VgNkl/L4ouD+rEomujw==", "dev": true }, "node_modules/@types/json-stable-stringify": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/@types/json-stable-stringify/-/json-stable-stringify-1.0.34.tgz", - "integrity": "sha512-s2cfwagOQAS8o06TcwKfr9Wx11dNGbH2E9vJz1cqV+a/LOyhWNLUNd6JSRYNzvB4d29UuJX2M0Dj9vE1T8fRXw==", + "version": "1.0.35", + "resolved": "https://registry.npmjs.org/@types/json-stable-stringify/-/json-stable-stringify-1.0.35.tgz", + "integrity": "sha512-zlCWqsRBI0+ANN7dzGeDFJ4CHaVFTLqBNRS11GjR2mHCW6XxNtnMxhQzBKMzfsnjI8oI+kWq2vBwinyQpZVSsg==", "dev": true }, "node_modules/@types/json5": { @@ -5452,9 +5453,9 @@ "dev": true }, "node_modules/@types/minimist": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.3.tgz", - "integrity": "sha512-ZYFzrvyWUNhaPomn80dsMNgMeXxNWZBdkuG/hWlUvXvbdUH8ZERNBGXnU87McuGcWDsyzX2aChCv/SVN348k3A==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.4.tgz", + "integrity": "sha512-Kfe/D3hxHTusnPNRbycJE1N77WHDsdS4AjUYIzlDzhDrS47NrwuL3YW4VITxwR7KCVpzwgy4Rbj829KSSQmwXQ==", "dev": true }, "node_modules/@types/node": { @@ -5464,27 +5465,27 @@ "dev": true }, "node_modules/@types/normalize-package-data": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.2.tgz", - "integrity": "sha512-lqa4UEhhv/2sjjIQgjX8B+RBjj47eo0mzGasklVJ78UKGQY1r0VpB9XHDaZZO9qzEFDdy4MrXLuEaSmPrPSe/A==", + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.3.tgz", + "integrity": "sha512-ehPtgRgaULsFG8x0NeYJvmyH1hmlfsNLujHe9dQEia/7MAJYdzMSi19JtchUHjmBA6XC/75dK55mzZH+RyieSg==", "dev": true }, "node_modules/@types/object.omit": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/object.omit/-/object.omit-3.0.1.tgz", - "integrity": "sha512-24XD34UeRWw505TsMNBrQ4bES2s8IxiFC59mmNUFhTz9IX2hAtA7gQ8wVww1i17QmhBYILg5iqYP2y7aqA3pwQ==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/object.omit/-/object.omit-3.0.2.tgz", + "integrity": "sha512-BxWU36cMP+FKD3OLFluQaj2cBev2sx2LJaHELuphHwnleq+xnEhTmuYYYx4pOT/1U/ZoR6B+RdvxWh2FD6lGGA==", "dev": true }, "node_modules/@types/object.pick": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@types/object.pick/-/object.pick-1.3.2.tgz", - "integrity": "sha512-sn7L+qQ6RLPdXRoiaE7bZ/Ek+o4uICma/lBFPyJEKDTPTBP1W8u0c4baj3EiS4DiqLs+Hk+KUGvMVJtAw3ePJg==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@types/object.pick/-/object.pick-1.3.3.tgz", + "integrity": "sha512-qZqHmdGEALeSATMB1djT1S5szv6Wtpb7DKpHrt2XG4iyKlV7C2Xk8GmDXr1KXakOqUfX6ohw7ceruYt4NVmB1Q==", "dev": true }, "node_modules/@types/parse-json": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", - "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.1.tgz", + "integrity": "sha512-3YmXzzPAdOTVljVMkTMBdBEvlOLg2cDQaDhnnhT3nT9uDbnJzjWhKlzb+desT12Y7tGqaN6d+AbozcKzyL36Ng==", "dev": true }, "node_modules/@types/prettier": { @@ -5494,15 +5495,15 @@ "dev": true }, "node_modules/@types/semver": { - "version": "7.5.3", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.3.tgz", - "integrity": "sha512-OxepLK9EuNEIPxWNME+C6WwbRAOOI2o2BaQEGzz5Lu2e4Z5eDnEo+/aVEDMIXywoJitJ7xWd641wrGLZdtwRyw==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-MMzuxN3GdFwskAnb6fz0orFvhfqi752yjaXylr0Rp4oDg5H0Zn1IuyRhDVvYOwAXoJirx2xuS16I3WjxnAIHiQ==", "dev": true }, "node_modules/@types/stack-utils": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", - "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.2.tgz", + "integrity": "sha512-g7CK9nHdwjK2n0ymT2CW698FuWJRIx+RP6embAzZ2Qi8/ilIrA1Imt2LVSeHUzKvpoi7BhmmQcXz95eS0f2JXw==", "dev": true }, "node_modules/@types/throttle-debounce": { @@ -5512,27 +5513,27 @@ "dev": true }, "node_modules/@types/ws": { - "version": "8.5.7", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.7.tgz", - "integrity": "sha512-6UrLjiDUvn40CMrAubXuIVtj2PEfKDffJS7ychvnPU44j+KVeXmdHHTgqcM/dxLUTHxlXHiFM8Skmb8ozGdTnQ==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.8.tgz", + "integrity": "sha512-flUksGIQCnJd6sZ1l5dqCEG/ksaoAg/eUwiLAGTJQcfgvZJKF++Ta4bJA6A5aPSJmsr+xlseHn4KLgVlNnvPTg==", "dev": true, "dependencies": { "@types/node": "*" } }, "node_modules/@types/yargs": { - "version": "17.0.28", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.28.tgz", - "integrity": "sha512-N3e3fkS86hNhtk6BEnc0rj3zcehaxx8QWhCROJkqpl5Zaoi7nAic3jH8q94jVD3zu5LGk+PUB6KAiDmimYOEQw==", + "version": "17.0.29", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.29.tgz", + "integrity": "sha512-nacjqA3ee9zRF/++a3FUY1suHTFKZeHba2n8WeDw9cCVdmzmHpIxyzOJBcpHvvEmS8E9KqWlSnWHUkOrkhWcvA==", "dev": true, "dependencies": { "@types/yargs-parser": "*" } }, "node_modules/@types/yargs-parser": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.1.tgz", - "integrity": "sha512-axdPBuLuEJt0c4yI5OZssC19K2Mq1uKdrfZBzuxLvaztgqUtFYZUNw7lETExPYJR9jdEoIg4mb7RQKRQzOkeGQ==", + "version": "21.0.2", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.2.tgz", + "integrity": "sha512-5qcvofLPbfjmBfKaLfj/+f+Sbd6pN4zl7w7VSVI5uz7m9QZTuB2aZAa2uo1wHFBNN2x6g/SoTkXmd8mQnQF2Cw==", "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { @@ -6868,9 +6869,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001549", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001549.tgz", - "integrity": "sha512-qRp48dPYSCYaP+KurZLhDYdVE+yEyht/3NlmcJgVQ2VMGt6JL36ndQ/7rgspdZsJuxDPFIo/OzBT2+GmIJ53BA==", + "version": "1.0.30001550", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001550.tgz", + "integrity": "sha512-p82WjBYIypO0ukTsd/FG3Xxs+4tFeaY9pfT4amQL8KWtYH7H9nYwReGAbMTJ0hsmRO8IfDtsS6p3ZWj8+1c2RQ==", "dev": true, "funding": [ { @@ -7913,9 +7914,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.556", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.556.tgz", - "integrity": "sha512-6RPN0hHfzDU8D56E72YkDvnLw5Cj2NMXZGg3UkgyoHxjVhG99KZpsKgBWMmTy0Ei89xwan+rbRsVB9yzATmYzQ==", + "version": "1.4.559", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.559.tgz", + "integrity": "sha512-iS7KhLYCSJbdo3rUSkhDTVuFNCV34RKs2UaB9Ecr7VlqzjjWW//0nfsFF5dtDmyXlZQaDYYtID5fjtC/6lpRug==", "dev": true }, "node_modules/emittery": { @@ -11439,6 +11440,15 @@ "node": ">=8" } }, + "node_modules/javascript-time-ago": { + "version": "2.5.9", + "resolved": "https://registry.npmjs.org/javascript-time-ago/-/javascript-time-ago-2.5.9.tgz", + "integrity": "sha512-pQ8mNco/9g9TqWXWWjP0EWl6i/lAQScOyEeXy5AB+f7MfLSdgyV9BJhiOD1zrIac/lrxPYOWNbyl/IW8CW5n0A==", + "dev": true, + "dependencies": { + "relative-time-format": "^1.1.6" + } + }, "node_modules/jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", @@ -16250,6 +16260,12 @@ "jsesc": "bin/jsesc" } }, + "node_modules/relative-time-format": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/relative-time-format/-/relative-time-format-1.1.6.tgz", + "integrity": "sha512-aCv3juQw4hT1/P/OrVltKWLlp15eW1GRcwP1XdxHrPdZE9MtgqFpegjnTjLhi2m2WI9MT/hQQtE+tjEWG1hgkQ==", + "dev": true + }, "node_modules/relay-runtime": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/relay-runtime/-/relay-runtime-12.0.0.tgz", diff --git a/package.json b/package.json index 8ad73e47..4daeecd0 100644 --- a/package.json +++ b/package.json @@ -117,6 +117,7 @@ "husky": "8.0.3", "hygen": "6.2.11", "i18next-http-backend": "2.2.0", + "javascript-time-ago": "2.5.9", "jest": "29.7.0", "js-cookie": "3.0.5", "lint-staged": "14.0.1", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 577ddfcf..cad81887 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -210,21 +210,17 @@ "New only": "New only", "New password": "New password", "New stories every day and even more!": "New stories and more are waiting for you every day!", - - "NotificationNewCommentText1": "{commentsCount, plural, one {New comment} other {{commentsCount} comments}} to your publication", - "NotificationNewCommentText2": "from", - "NotificationNewCommentText3": "{restUsersCount, plural, =0 {} one { one more user} other { and more {restUsersCount} users}}", - - "NotificationNewReplyText1": "{commentsCount, plural, one {New reply} other {{commentsCount} replays}} to your publication", - "NotificationNewReplyText2": "from", - "NotificationNewReplyText3": "{restUsersCount, plural, =0 {} one { and one more user} other { and more {restUsersCount} users}}", - "Newsletter": "Newsletter", "Night mode": "Night mode", "No notifications yet": "No notifications yet", - "Write good articles, comment\nand it won't be so empty here": "Write good articles, comment\nand it won't be so empty here", "Nothing here yet": "There's nothing here yet", "Nothing is here": "There is nothing here", + "NotificationNewCommentText1": "{commentsCount, plural, one {New comment} other {{commentsCount} comments}} to your publication", + "NotificationNewCommentText2": "from", + "NotificationNewCommentText3": "{restUsersCount, plural, =0 {} one { one more user} other { and more {restUsersCount} users}}", + "NotificationNewReplyText1": "{commentsCount, plural, one {New reply} other {{commentsCount} replays}} to your publication", + "NotificationNewReplyText2": "from", + "NotificationNewReplyText3": "{restUsersCount, plural, =0 {} one { and one more user} other { and more {restUsersCount} users}}", "Notifications": "Notifications", "Or paste a link to an image": "Or paste a link to an image", "Ordered list": "Ordered list", @@ -369,6 +365,7 @@ "Write about the topic": "Write about the topic", "Write an article": "Write an article", "Write comment": "Write comment", + "Write good articles, comment\nand it won't be so empty here": "Write good articles, comment\nand it won't be so empty here", "Write message": "Write a message", "Write to us": "Write to us", "You can download multiple tracks at once in .mp3, .wav or .flac formats": "You can download multiple tracks at once in .mp3, .wav or .flac formats", @@ -385,6 +382,7 @@ "article": "article", "author": "author", "authors": "authors", + "authorsWithCount": "{count} {count, plural, one {author} other {authors}}", "back to menu": "back to menu", "bold": "bold", "bookmarks": "bookmarks", @@ -395,10 +393,12 @@ "delimiter": "delimiter", "discussion": "discourse", "drafts": "drafts", + "earlier": "earlier", "email not confirmed": "email not confirmed", "enter": "enter", "feed": "feed", "follower": "follower", + "followersWithCount": "{count} {count, plural, one {follower} other {followers}}", "general feed": "general tape", "header 1": "header 1", "header 2": "header 2", @@ -420,6 +420,7 @@ "register": "register", "repeat": "repeat", "shout": "post", + "shoutsWithCount": "{count} {count, plural, one {post} other {posts}}", "sign up or sign in": "sign up or sign in", "slug is used by another user": "Slug is already taken by another user", "subscriber": "subscriber", @@ -429,8 +430,10 @@ "subscription_rp": "subscription", "subscriptions": "subscriptions", "terms of use": "terms of use", + "today": "today", "topics": "topics", "user already exist": "user already exists", "video": "video", - "view": "view" + "view": "view", + "yesterday": "yesterday" } diff --git a/public/locales/ru/translation.json b/public/locales/ru/translation.json index b8f5d551..07af69c1 100644 --- a/public/locales/ru/translation.json +++ b/public/locales/ru/translation.json @@ -220,22 +220,18 @@ "New only": "Только новые", "New password": "Новый пароль", "New stories every day and even more!": "Каждый день вас ждут новые истории и ещё много всего интересного!", - - "NotificationNewCommentText1": "{commentsCount, plural, one {Новый комментарий} few {{commentsCount} новых комментария} other {{commentsCount} новых комментариев}} к вашей публикации", - "NotificationNewCommentText2": "от", - "NotificationNewCommentText3": "{restUsersCount, plural, =0 {} one { и ещё 1 пользователя} few { и ещё {restUsersCount} пользователей} other { и ещё {restUsersCount} пользователей}}", - - "NotificationNewReplyText1": "{commentsCount, plural, one {Новый ответ} few {{commentsCount} новых ответа} other {{commentsCount} новых ответов}} на ваш комментарий к публикации", - "NotificationNewReplyText2": "от", - "NotificationNewReplyText3": "{restUsersCount, plural, =0 {} one { и ещё 1 пользователя} few { и ещё {restUsersCount} пользователей} other { и ещё {restUsersCount} пользователей}}", - "Newsletter": "Рассылка", "Night mode": "Ночная тема", "No notifications yet": "Уведомлений пока нет", - "Write good articles, comment\nand it won't be so empty here": "Пишите хорошие статьи, комментируйте,\nи здесь станет не так пусто", "No such account, please try to register": "Такой адрес не найден, попробуйте зарегистрироваться", "Nothing here yet": "Здесь пока ничего нет", "Nothing is here": "Здесь ничего нет", + "NotificationNewCommentText1": "{commentsCount, plural, one {Новый комментарий} few {{commentsCount} новых комментария} other {{commentsCount} новых комментариев}} к вашей публикации", + "NotificationNewCommentText2": "от", + "NotificationNewCommentText3": "{restUsersCount, plural, =0 {} one { и ещё 1 пользователя} few { и ещё {restUsersCount} пользователей} other { и ещё {restUsersCount} пользователей}}", + "NotificationNewReplyText1": "{commentsCount, plural, one {Новый ответ} few {{commentsCount} новых ответа} other {{commentsCount} новых ответов}} на ваш комментарий к публикации", + "NotificationNewReplyText2": "от", + "NotificationNewReplyText3": "{restUsersCount, plural, =0 {} one { и ещё 1 пользователя} few { и ещё {restUsersCount} пользователей} other { и ещё {restUsersCount} пользователей}}", "Notifications": "Уведомления", "Or paste a link to an image": "Или вставьте ссылку на изображение", "Ordered list": "Нумерованный список", @@ -389,6 +385,7 @@ "Write about the topic": "Написать в тему", "Write an article": "Написать статью", "Write comment": "Написать комментарий", + "Write good articles, comment\nand it won't be so empty here": "Пишите хорошие статьи, комментируйте,\nи здесь станет не так пусто", "Write message": "Написать сообщение", "Write to us": "Напишите нам", "You can download multiple tracks at once in .mp3, .wav or .flac formats": "Можно загрузить сразу несколько треков в форматах .mp3, .wav или .flac", @@ -405,6 +402,7 @@ "article": "статья", "author": "автор", "authors": "авторы", + "authorsWithCount": "{count} {count, plural, one {автор} few {автора} other {авторов}}", "back to menu": "назад в меню", "bold": "жирный", "bookmarks": "закладки", @@ -418,10 +416,12 @@ "discourse_theme": "Тема дискурса", "discussion": "дискурс", "drafts": "черновики", + "earlier": "ранее", "email not confirmed": "email не подтвержден", "enter": "войдите", "feed": "лента", "follower": "подписчик", + "followersWithCount": "{count} {count, plural, one {подписчик} few {подписчика} other {подписчиков}}", "general feed": "Общая лента", "header 1": "заголовок 1", "header 2": "заголовок 2", @@ -444,6 +444,7 @@ "register": "зарегистрируйтесь", "repeat": "повторить", "shout": "пост", + "shoutsWithCount": "{count} {count, plural, one {пост} few {поста} other {постов}}", "sign in": "войти", "sign up": "зарегистрироваться", "sign up or sign in": "зарегистрироваться или войти", @@ -453,8 +454,10 @@ "subscriber_rp": "подписчика", "subscribers": "подписчиков", "terms of use": "правилами пользования сайтом", + "today": "сегодня", "topics": "темы", "user already exist": "пользователь уже существует", "video": "видео", - "view": "просмотр" + "view": "просмотр", + "yesterday": "вчера" } diff --git a/src/components/Article/CommentDate.tsx b/src/components/Article/CommentDate.tsx index 28500a9d..1d26de95 100644 --- a/src/components/Article/CommentDate.tsx +++ b/src/components/Article/CommentDate.tsx @@ -1,7 +1,6 @@ import { Show } from 'solid-js' import { Icon } from '../_shared/Icon' import type { Reaction } from '../../graphql/types.gen' -import { formatDate } from '../../utils' import { useLocalize } from '../../context/localize' import { clsx } from 'clsx' import styles from './CommentDate.module.scss' @@ -13,9 +12,9 @@ type Props = { } export const CommentDate = (props: Props) => { - const { t } = useLocalize() + const { t, formatDate } = useLocalize() - const formattedDate = (date) => { + const formattedDate = (date: number) => { const formatDateOptions: Intl.DateTimeFormatOptions = props.isShort ? { month: 'long', day: 'numeric', year: 'numeric' } : { hour: 'numeric', minute: 'numeric' } diff --git a/src/components/Article/FullArticle.tsx b/src/components/Article/FullArticle.tsx index 7647b2b1..3f26f74f 100644 --- a/src/components/Article/FullArticle.tsx +++ b/src/components/Article/FullArticle.tsx @@ -8,8 +8,7 @@ import { useSession } from '../../context/session' import { useLocalize } from '../../context/localize' import { useReactions } from '../../context/reactions' import { MediaItem } from '../../pages/types' -import { router, useRouter } from '../../stores/router' -import { formatDate } from '../../utils' +import { DEFAULT_HEADER_OFFSET, router, useRouter } from '../../stores/router' import { getDescription } from '../../utils/meta' import { imageProxy } from '../../utils/imageProxy' import { AuthorCard } from '../Author/AuthorCard' @@ -42,14 +41,14 @@ const scrollTo = (el: HTMLElement) => { const { top } = el.getBoundingClientRect() window.scrollTo({ - top: top + window.scrollY - 96, + top: top + window.scrollY - DEFAULT_HEADER_OFFSET, left: 0, behavior: 'smooth' }) } export const FullArticle = (props: Props) => { - const { t } = useLocalize() + const { t, formatDate } = useLocalize() const { user, isAuthenticated, diff --git a/src/components/Author/AuthorBadge/AuthorBadge.tsx b/src/components/Author/AuthorBadge/AuthorBadge.tsx index 772b4479..e8730fb3 100644 --- a/src/components/Author/AuthorBadge/AuthorBadge.tsx +++ b/src/components/Author/AuthorBadge/AuthorBadge.tsx @@ -3,7 +3,6 @@ import styles from './AuthorBadge.module.scss' import { Userpic } from '../Userpic' import { Author, FollowingEntity } from '../../../graphql/types.gen' import { createMemo, createSignal, Match, Show, Switch } from 'solid-js' -import { formatDate } from '../../../utils' import { useLocalize } from '../../../context/localize' import { Button } from '../../_shared/Button' import { useSession } from '../../../context/session' @@ -21,7 +20,7 @@ export const AuthorBadge = (props: Props) => { actions: { loadSession, requireAuthentication } } = useSession() - const { t } = useLocalize() + const { t, formatDate } = useLocalize() const subscribed = createMemo(() => { return session()?.news?.authors?.some((u) => u === props.author.slug) || false }) diff --git a/src/components/Author/AuthorCard/AuthorCard.tsx b/src/components/Author/AuthorCard/AuthorCard.tsx index 75e6b2f2..9ac66a04 100644 --- a/src/components/Author/AuthorCard/AuthorCard.tsx +++ b/src/components/Author/AuthorCard/AuthorCard.tsx @@ -1,8 +1,7 @@ import type { Author } from '../../../graphql/types.gen' import { Userpic } from '../Userpic' import { Icon } from '../../_shared/Icon' -import styles from './AuthorCard.module.scss' -import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from 'solid-js' +import { createEffect, createMemo, createSignal, For, Show } from 'solid-js' import { translit } from '../../../utils/ru2en' import { follow, unfollow } from '../../../stores/zine/common' import { clsx } from 'clsx' @@ -20,7 +19,7 @@ import { AuthorBadge } from '../AuthorBadge' import { TopicBadge } from '../../Topic/TopicBadge' import { Button } from '../../_shared/Button' import { getShareUrl, SharePopup } from '../../Article/SharePopup' -import stylesHeader from '../../Nav/Header/Header.module.scss' +import styles from './AuthorCard.module.scss' type Props = { caption?: string diff --git a/src/components/Draft/Draft.tsx b/src/components/Draft/Draft.tsx index 7ee25154..32d6edcb 100644 --- a/src/components/Draft/Draft.tsx +++ b/src/components/Draft/Draft.tsx @@ -2,8 +2,6 @@ import { clsx } from 'clsx' import styles from './Draft.module.scss' import type { Shout } from '../../graphql/types.gen' import { Icon } from '../_shared/Icon' -import { formatDate } from '../../utils' -import formatDateTime from '../../utils/formatDateTime' import { useLocalize } from '../../context/localize' import { useConfirm } from '../../context/confirm' import { useSnackbar } from '../../context/snackbar' @@ -18,7 +16,7 @@ type Props = { } export const Draft = (props: Props) => { - const { t } = useLocalize() + const { t, formatDate } = useLocalize() const { actions: { showConfirm } } = useConfirm() @@ -51,8 +49,8 @@ export const Draft = (props: Props) => { return (
- {formatDate(new Date(props.shout.createdAt))} -  {formatDateTime(props.shout.createdAt)()} + {' '} + {formatDate(new Date(props.shout.createdAt), { hour: '2-digit', minute: '2-digit' })}
{props.shout.title || t('Unnamed draft')} {props.shout.subtitle} diff --git a/src/components/Feed/ArticleCard.tsx b/src/components/Feed/ArticleCard.tsx index 4ddd6551..6799fa03 100644 --- a/src/components/Feed/ArticleCard.tsx +++ b/src/components/Feed/ArticleCard.tsx @@ -1,6 +1,5 @@ import { createMemo, createSignal, For, Show } from 'solid-js' import type { Shout } from '../../graphql/types.gen' -import { capitalize, formatDate } from '../../utils' import { Icon } from '../_shared/Icon' import styles from './ArticleCard.module.scss' import { clsx } from 'clsx' @@ -17,6 +16,7 @@ import { imageProxy } from '../../utils/imageProxy' import { Popover } from '../_shared/Popover' import { AuthorCard } from '../Author/AuthorCard' import { useSession } from '../../context/session' +import { capitalize } from '../../utils/capitalize' interface ArticleCardProps { settings?: { @@ -44,7 +44,12 @@ interface ArticleCardProps { article: Shout } -const getTitleAndSubtitle = (article: Shout): { title: string; subtitle: string } => { +const getTitleAndSubtitle = ( + article: Shout +): { + title: string + subtitle: string +} => { let title = article.title let subtitle = article.subtitle @@ -66,14 +71,14 @@ const getTitleAndSubtitle = (article: Shout): { title: string; subtitle: string } export const ArticleCard = (props: ArticleCardProps) => { - const { t, lang } = useLocalize() + const { t, lang, formatDate } = useLocalize() const { user } = useSession() const mainTopic = props.article.topics.find((articleTopic) => articleTopic.slug === props.article.mainTopic) || props.article.topics[0] const formattedDate = createMemo(() => { - return formatDate(new Date(props.article.createdAt), { month: 'long', day: 'numeric', year: 'numeric' }) + return formatDate(new Date(props.article.createdAt)) }) const { title, subtitle } = getTitleAndSubtitle(props.article) diff --git a/src/components/Inbox/DialogCard.tsx b/src/components/Inbox/DialogCard.tsx index 0e5021de..4a671ab0 100644 --- a/src/components/Inbox/DialogCard.tsx +++ b/src/components/Inbox/DialogCard.tsx @@ -2,7 +2,6 @@ import { Show, Switch, Match, createMemo } from 'solid-js' import DialogAvatar from './DialogAvatar' import type { ChatMember } from '../../graphql/types.gen' import GroupDialogAvatar from './GroupDialogAvatar' -import formattedTime from '../../utils/formatDateTime' import { clsx } from 'clsx' import styles from './DialogCard.module.scss' import { useLocalize } from '../../context/localize' @@ -20,7 +19,7 @@ type DialogProps = { } const DialogCard = (props: DialogProps) => { - const { t } = useLocalize() + const { t, formatTime } = useLocalize() const companions = createMemo( () => props.members && props.members.filter((member) => member.id !== props.ownId) ) @@ -64,7 +63,7 @@ const DialogCard = (props: DialogProps) => {
-
{formattedTime(props.lastUpdate * 1000)()}
+
{formatTime(new Date(props.lastUpdate * 1000))}
0}>
diff --git a/src/components/Inbox/Message.tsx b/src/components/Inbox/Message.tsx index 9a345cf4..c9a9df00 100644 --- a/src/components/Inbox/Message.tsx +++ b/src/components/Inbox/Message.tsx @@ -3,10 +3,10 @@ import { clsx } from 'clsx' import styles from './Message.module.scss' import DialogAvatar from './DialogAvatar' import type { Message as MessageType, ChatMember } from '../../graphql/types.gen' -import formattedTime from '../../utils/formatDateTime' import { Icon } from '../_shared/Icon' import { MessageActionsPopup } from './MessageActionsPopup' import QuotedMessage from './QuotedMessage' +import { useLocalize } from '../../context/localize' type Props = { content: MessageType @@ -18,6 +18,7 @@ type Props = { } export const Message = (props: Props) => { + const { formatTime } = useLocalize() const isOwn = props.ownId === Number(props.content.author) const user = props.members?.find((m) => m.id === Number(props.content.author)) const [isPopupVisible, setIsPopupVisible] = createSignal(false) @@ -47,7 +48,7 @@ export const Message = (props: Props) => {
-
{formattedTime(props.content.createdAt * 1000)()}
+
{formatTime(new Date(props.content.createdAt * 1000))}
) } diff --git a/src/components/NotificationsPanel/NotificationView/NotificationView.module.scss b/src/components/NotificationsPanel/NotificationView/NotificationView.module.scss index 26401acd..b81dab01 100644 --- a/src/components/NotificationsPanel/NotificationView/NotificationView.module.scss +++ b/src/components/NotificationsPanel/NotificationView/NotificationView.module.scss @@ -36,4 +36,9 @@ .timeContainer { margin-left: auto; padding-left: 16px; + color: var(--black-400); + font-size: 12px; + font-weight: 500; + line-height: 16px; + align-self: flex-start; } diff --git a/src/components/NotificationsPanel/NotificationView/NotificationView.tsx b/src/components/NotificationsPanel/NotificationView/NotificationView.tsx index 7b068b57..e0e70cad 100644 --- a/src/components/NotificationsPanel/NotificationView/NotificationView.tsx +++ b/src/components/NotificationsPanel/NotificationView/NotificationView.tsx @@ -1,17 +1,19 @@ import { clsx } from 'clsx' -import styles from './NotificationView.module.scss' -import { formatDate } from '../../../utils' import { createMemo, createSignal, onMount, Show } from 'solid-js' import { Author } from '../../../graphql/types.gen' import { openPage } from '@nanostores/router' -import { router } from '../../../stores/router' +import { router, useRouter } from '../../../stores/router' import { ServerNotification, useNotifications } from '../../../context/notifications' import { Userpic } from '../../Author/Userpic' import { useLocalize } from '../../../context/localize' +import type { ArticlePageSearchParams } from '../../Article/FullArticle' +import { TimeAgo } from '../../_shared/TimeAgo' +import styles from './NotificationView.module.scss' type Props = { notification: ServerNotification onClick: () => void + dateTimeFormat: 'ago' | 'time' | 'date' class?: string } @@ -49,9 +51,11 @@ export const NotificationView = (props: Props) => { const { actions: { markNotificationAsRead } } = useNotifications() - const { t } = useLocalize() const [data, setData] = createSignal(null) const [kind, setKind] = createSignal() + const { changeSearchParam } = useRouter() + const { t, formatDate, formatTime } = useLocalize() + onMount(() => { setTimeout(() => setData(props.notification)) }) @@ -110,6 +114,20 @@ export const NotificationView = (props: Props) => { props.onClick() } + const formattedDateTime = createMemo(() => { + switch (props.dateTimeFormat) { + case 'ago': { + return + } + case 'time': { + return formatTime(new Date(props.notification.timestamp)) + } + case 'date': { + return formatDate(new Date(props.notification.timestamp), { month: 'numeric', year: '2-digit' }) + } + } + }) + return (
{ >
{content()}
-
- {/*{formatDate(new Date(props.notification.timestamp), { month: 'numeric' })}*/} -
+
{formattedDateTime()}
) diff --git a/src/components/NotificationsPanel/NotificationsPanel.module.scss b/src/components/NotificationsPanel/NotificationsPanel.module.scss index a1b18dd0..ee997569 100644 --- a/src/components/NotificationsPanel/NotificationsPanel.module.scss +++ b/src/components/NotificationsPanel/NotificationsPanel.module.scss @@ -64,3 +64,12 @@ $transition-duration: 200ms; .emptyMessageContainer { text-align: center; } + +.periodTitle { + // TODO: check markup + margin: 32px 0 16px 0; + color: var(--black-400); + font-size: 12px; + font-weight: 500; + line-height: 14px; +} diff --git a/src/components/NotificationsPanel/NotificationsPanel.tsx b/src/components/NotificationsPanel/NotificationsPanel.tsx index c47e770f..3c7c09fa 100644 --- a/src/components/NotificationsPanel/NotificationsPanel.tsx +++ b/src/components/NotificationsPanel/NotificationsPanel.tsx @@ -4,7 +4,7 @@ import { useEscKeyDownHandler } from '../../utils/useEscKeyDownHandler' import { useOutsideClickHandler } from '../../utils/useOutsideClickHandler' import { useLocalize } from '../../context/localize' import { Icon } from '../_shared/Icon' -import { createEffect, For } from 'solid-js' +import { createEffect, createMemo, For, Show } from 'solid-js' import { useNotifications } from '../../context/notifications' import { NotificationView } from './NotificationView' import { EmptyMessage } from './EmptyMessage' @@ -14,6 +14,30 @@ type Props = { onClose: () => void } +const getYesterdayStart = () => { + const now = new Date() + return new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 0, 0, 0, 0) +} + +const isSameDate = (date1: Date, date2: Date) => + date1.getDate() === date2.getDate() && + date1.getMonth() === date2.getMonth() && + date1.getFullYear() === date2.getFullYear() + +const isToday = (date: Date) => { + return isSameDate(date, new Date()) +} + +const isYesterday = (date: Date) => { + const yesterday = getYesterdayStart() + return isSameDate(date, yesterday) +} + +const isEarlier = (date: Date) => { + const yesterday = getYesterdayStart() + return date.getTime() < yesterday.getTime() +} + export const NotificationsPanel = (props: Props) => { const { t } = useLocalize() const { sortedNotifications } = useNotifications() @@ -55,6 +79,18 @@ export const NotificationsPanel = (props: Props) => { handleHide() } + const todayNotifications = createMemo(() => { + return sortedNotifications().filter((notification) => isToday(new Date(notification.createdAt))) + }) + + const yesterdayNotifications = createMemo(() => { + return sortedNotifications().filter((notification) => isYesterday(new Date(notification.createdAt))) + }) + + const earlierNotifications = createMemo(() => { + return sortedNotifications().filter((notification) => isEarlier(new Date(notification.createdAt))) + }) + return (
{
{t('Notifications')}
- }> - {(notification) => ( - - )} - + 0} fallback={}> + 0}> +
{t('today')}
+ + {(notification) => ( + + )} + +
+ 0}> +
{t('yesterday')}
+ + {(notification) => ( + + )} + +
+ 0}> +
{t('earlier')}
+ + {(notification) => ( + + )} + +
+
) diff --git a/src/components/Topic/Card.tsx b/src/components/Topic/Card.tsx index da355cf5..6f05aac4 100644 --- a/src/components/Topic/Card.tsx +++ b/src/components/Topic/Card.tsx @@ -1,5 +1,3 @@ -import { capitalize } from '../../utils' -import styles from './Card.module.scss' import { createMemo, createSignal, Show } from 'solid-js' import type { Topic } from '../../graphql/types.gen' import { FollowingEntity } from '../../graphql/types.gen' @@ -12,6 +10,9 @@ import { Icon } from '../_shared/Icon' import { useLocalize } from '../../context/localize' import { CardTopic } from '../Feed/CardTopic' import { CheckButton } from '../_shared/CheckButton' +import { capitalize } from '../../utils/capitalize' + +import styles from './Card.module.scss' interface TopicProps { topic: Topic @@ -109,14 +110,6 @@ export const TopicCard = (props: TopicProps) => { {props.topic.body} - 0}> -
- {props.topic.stat?.shouts} публикаций -
-
{ ) return ( -
+
0}>
diff --git a/src/components/Views/AllTopics.module.scss b/src/components/Views/AllTopics.module.scss new file mode 100644 index 00000000..fe7b931c --- /dev/null +++ b/src/components/Views/AllTopics.module.scss @@ -0,0 +1,115 @@ +.allTopicsPage { + .group { + @include font-size(1.6rem); + + margin: 3em 0 9.6rem; + + @include media-breakpoint-down(sm) { + margin-bottom: 6.4rem; + } + + h2 { + margin-bottom: 3.2rem; + text-transform: capitalize; + + @include media-breakpoint-down(sm) { + margin-bottom: 1.6rem; + } + } + + .topic { + margin-bottom: 2.4rem; + } + } + + .container { + width: auto; + + .search-input { + display: inline-block; + width: 100px !important; + } + } +} + +.stats { + @include font-size(1.7rem); + + color: #9fa1a7; + display: flex; + margin: 0 0 1em; + + @include media-breakpoint-down(md) { + flex-wrap: wrap; + } + + @include media-breakpoint-down(sm) { + margin-top: 0.5em; + } + + .statsItem { + @include font-size(1.5rem); + + margin-right: 1.6rem; + white-space: nowrap; + + &:last-child { + margin-right: 0; + } + + &.compact { + font-size: small; + } + + &.followers { + word-break: keep-all; + } + + &.button { + float: right; + } + } +} + +.loadMoreContainer { + margin-top: 48px; + text-align: center; + + .loadMoreButton { + padding: 0.6em 3em; + width: 100%; + + @include media-breakpoint-up(sm) { + width: auto; + } + } +} + +.alphabet { + @include font-size(1.5rem); + color: rgba(0 0 0 / 20%); + display: flex; + flex-wrap: wrap; + font-weight: 700; + margin: 1.5em -3% 0 0; + + li { + min-width: 1.5em; + margin-right: 3%; + color: rgb(0 0 0 / 30%); + } + + a { + border: none; + } +} + +.articlesCounter { + @include font-size(1.2rem); + margin-left: 0.5em; + vertical-align: super; +} + +.viewSwitcher { + margin-bottom: 2rem; +} diff --git a/src/components/Views/AllTopics.tsx b/src/components/Views/AllTopics.tsx index d8ec3491..f9c16bc2 100644 --- a/src/components/Views/AllTopics.tsx +++ b/src/components/Views/AllTopics.tsx @@ -6,13 +6,13 @@ import { useRouter } from '../../stores/router' import { TopicCard } from '../Topic/Card' import { clsx } from 'clsx' import { useSession } from '../../context/session' -import styles from '../../styles/AllTopics.module.scss' import { SearchField } from '../_shared/SearchField' import { scrollHandler } from '../../utils/scroll' -import { StatMetrics } from '../_shared/StatMetrics' import { useLocalize } from '../../context/localize' import { dummyFilter } from '../../utils/dummyFilter' +import styles from './AllTopics.module.scss' + type AllTopicsPageSearchParams = { by: 'shouts' | 'authors' | 'title' | '' } @@ -168,7 +168,17 @@ export const AllTopicsView = (props: AllTopicsViewProps) => { showPublications={true} showDescription={true} /> - +
+ + {t('shoutsWithCount', { count: topic.stat.shouts })} + + + {t('authorsWithCount', { count: topic.stat.authors })} + + + {t('followersWithCount', { count: topic.stat.followers })} + +
)} diff --git a/src/components/_shared/Stat.module.scss b/src/components/_shared/Stat.module.scss deleted file mode 100644 index e40f76b5..00000000 --- a/src/components/_shared/Stat.module.scss +++ /dev/null @@ -1,36 +0,0 @@ -.statMetrics { - @include font-size(1.7rem); - color: #9fa1a7; - display: flex; - margin: 0 0 1em; - - @include media-breakpoint-down(md) { - flex-wrap: wrap; - } - - @include media-breakpoint-down(sm) { - margin-top: 0.5em; - } -} - -.statMetricsItem { - @include font-size(1.5rem); - margin-right: 1.6rem; - white-space: nowrap; - - &:last-child { - margin-right: 0; - } - - &.compact { - font-size: small; - } - - &.followers { - word-break: keep-all; - } - - &.button { - float: right; - } -} diff --git a/src/components/_shared/StatMetrics.tsx b/src/components/_shared/StatMetrics.tsx deleted file mode 100644 index de2697a5..00000000 --- a/src/components/_shared/StatMetrics.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { For } from 'solid-js' -import type { Stat, TopicStat } from '../../graphql/types.gen' -import { plural } from '../../utils' -import styles from './Stat.module.scss' -import { useLocalize } from '../../context/localize' - -interface StatMetricsProps { - fields?: string[] - stat: Stat | TopicStat - compact?: boolean -} - -const pseudonames = { - // topics: 'topics' # amount of topics for community💥 - followed: 'follower', - followers: 'follower', - rating: 'like', - viewed: 'view', - views: 'view', - reacted: 'involving', - reactions: 'involving', - commented: 'discussion', - comments: 'discussion', - shouts: 'post', - authors: 'author' -} - -export const StatMetrics = (props: StatMetricsProps) => { - const { t, lang } = useLocalize() - - return ( -
- - {(entity: string) => ( - - {props.stat[entity] + - ' ' + - t(pseudonames[entity] || entity.slice(-1)) + - plural(props.stat[entity] || 0, lang() === 'ru' ? ['ов', '', 'а'] : ['s', '', 's'])} - - )} - -
- ) -} diff --git a/src/components/_shared/TimeAgo/TimeAgo.module.scss b/src/components/_shared/TimeAgo/TimeAgo.module.scss new file mode 100644 index 00000000..6aee4238 --- /dev/null +++ b/src/components/_shared/TimeAgo/TimeAgo.module.scss @@ -0,0 +1,3 @@ +.TimeAgo { + white-space: nowrap; +} diff --git a/src/components/_shared/TimeAgo/TimeAgo.tsx b/src/components/_shared/TimeAgo/TimeAgo.tsx new file mode 100644 index 00000000..cb1cca0a --- /dev/null +++ b/src/components/_shared/TimeAgo/TimeAgo.tsx @@ -0,0 +1,37 @@ +import { clsx } from 'clsx' +import { useLocalize } from '../../../context/localize' +import { createSignal, onCleanup, onMount } from 'solid-js' + +import styles from './TimeAgo.module.scss' + +type Props = { + date: any + class?: string +} + +export const TimeAgo = (props: Props) => { + const { formatDate, formatTimeAgo } = useLocalize() + const [formattedTimeAgo, setFormattedTimeAgo] = createSignal(formatTimeAgo(new Date(props.date))) + + onMount(() => { + let timerId: NodeJS.Timeout + const updateTimeAgo = () => { + timerId = setTimeout(() => { + setFormattedTimeAgo(formatTimeAgo(new Date(props.date))) + updateTimeAgo() + }, 1000) + } + updateTimeAgo() + + onCleanup(() => clearTimeout(timerId)) + }) + + return ( +
+ {formattedTimeAgo()} +
+ ) +} diff --git a/src/components/_shared/TimeAgo/index.ts b/src/components/_shared/TimeAgo/index.ts new file mode 100644 index 00000000..0c64b085 --- /dev/null +++ b/src/components/_shared/TimeAgo/index.ts @@ -0,0 +1 @@ +export { TimeAgo } from './TimeAgo' diff --git a/src/context/localize.tsx b/src/context/localize.tsx index 166da80b..c307bb6e 100644 --- a/src/context/localize.tsx +++ b/src/context/localize.tsx @@ -1,14 +1,23 @@ import type { i18n } from 'i18next' import type { Accessor, JSX } from 'solid-js' -import { createContext, createEffect, createSignal, Show, useContext } from 'solid-js' +import { createContext, createEffect, createMemo, createSignal, Show, useContext } from 'solid-js' import { useRouter } from '../stores/router' import i18next, { changeLanguage, t } from 'i18next' import Cookie from 'js-cookie' +import TimeAgo from 'javascript-time-ago' +import en from 'javascript-time-ago/locale/en' +import ru from 'javascript-time-ago/locale/ru' + +TimeAgo.addLocale(en) +TimeAgo.addLocale(ru) type LocalizeContextType = { t: i18n['t'] lang: Accessor setLang: (lang: Language) => void + formatTime: (date: Date, options?: Intl.DateTimeFormatOptions) => string + formatDate: (date: Date, options?: Intl.DateTimeFormatOptions) => string + formatTimeAgo: (date: Date) => string } export type Language = 'ru' | 'en' @@ -21,7 +30,9 @@ export function useLocalize() { export const LocalizeProvider = (props: { children: JSX.Element }) => { const [lang, setLang] = createSignal(i18next.language === 'en' ? 'en' : 'ru') - const { searchParams, changeSearchParam } = useRouter<{ lng: string }>() + const { searchParams, changeSearchParam } = useRouter<{ + lng: string + }>() createEffect(() => { if (!searchParams().lng) { @@ -36,7 +47,43 @@ export const LocalizeProvider = (props: { children: JSX.Element }) => { changeSearchParam({ lng: null }, true) }) - const value: LocalizeContextType = { t, lang, setLang } + const formatTime = (date: Date, options: Intl.DateTimeFormatOptions = {}) => { + const opts = Object.assign( + {}, + { + hour: '2-digit', + minute: '2-digit' + }, + options + ) + + return date.toLocaleTimeString(lang(), opts) + } + + const formatDate = (date: Date, options: Intl.DateTimeFormatOptions = {}) => { + const opts = Object.assign( + {}, + { + month: 'long', + day: 'numeric', + year: 'numeric' + }, + options + ) + + let result = date.toLocaleDateString(lang(), opts) + if (lang() === 'ru') { + result = result.replace(' г.', '') + } + + return result + } + + const timeAgo = createMemo(() => new TimeAgo(lang())) + + const formatTimeAgo = (date: Date) => timeAgo().format(date) + + const value: LocalizeContextType = { t, lang, setLang, formatTime, formatDate, formatTimeAgo } return ( diff --git a/src/context/notifications.tsx b/src/context/notifications.tsx index b7986970..cf5f2aac 100644 --- a/src/context/notifications.tsx +++ b/src/context/notifications.tsx @@ -95,7 +95,7 @@ export const NotificationsProvider = (props: { children: JSX.Element }) => { loadNotifications() const token = getToken() - const eventSource = new EventSource(`https://chat.discours.io/connect/?token=${token}`) + const eventSource = new EventSource(`https://connect.discours.io/${token}`) eventSource.onmessage = (event) => { console.log('[context.notifications] Received event:', event) diff --git a/src/renderer/_default.page.server.tsx b/src/renderer/_default.page.server.tsx index 1501e428..fa656cef 100644 --- a/src/renderer/_default.page.server.tsx +++ b/src/renderer/_default.page.server.tsx @@ -4,10 +4,11 @@ import { App } from '../components/App' import { initRouter } from '../stores/router' import type { PageContext } from './types' import { MetaProvider, renderTags } from '@solidjs/meta' -import i18next, { changeLanguage, init as initI18next } from 'i18next' +import i18next from 'i18next' import ru from '../../public/locales/ru/translation.json' import en from '../../public/locales/en/translation.json' import type { Language } from '../context/localize' +import ICU from 'i18next-icu' export const passToClient = ['pageProps', 'lng', 'documentProps', 'is404'] @@ -32,7 +33,7 @@ export const render = async (pageContext: PageContext) => { if (!i18next.isInitialized) { // eslint-disable-next-line import/no-named-as-default-member - await initI18next({ + await i18next.use(ICU).init({ // debug: true, supportedLngs: ['ru', 'en'], fallbackLng: lng, @@ -44,7 +45,7 @@ export const render = async (pageContext: PageContext) => { } }) } else if (i18next.language !== lng) { - await changeLanguage(lng) + await i18next.changeLanguage(lng) } if (pageContext.is404) { diff --git a/src/utils/apiClient.ts b/src/utils/apiClient.ts index 5ab7e9e4..085daeaa 100644 --- a/src/utils/apiClient.ts +++ b/src/utils/apiClient.ts @@ -357,7 +357,7 @@ export const apiClient = { /* getNotifications: async (params: NotificationsQueryParams): Promise => { const resp = await privateGraphQLClient.query(notifications, params).toPromise() - console.debug(resp.data) + // console.debug(resp.data) return resp.data.loadNotifications }, markNotificationAsRead: async (notificationId: number): Promise => { diff --git a/src/utils/capitalize.ts b/src/utils/capitalize.ts new file mode 100644 index 00000000..8e4ac45d --- /dev/null +++ b/src/utils/capitalize.ts @@ -0,0 +1,9 @@ +export const capitalize = (originalString: string, firstonly = false) => { + const s = originalString.trim() + return firstonly + ? s.charAt(0).toUpperCase() + s.slice(1) + : s + .split(' ') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' ') +} diff --git a/src/utils/dummyFilter.ts b/src/utils/dummyFilter.ts index fe4d8f4b..3b68b38b 100644 --- a/src/utils/dummyFilter.ts +++ b/src/utils/dummyFilter.ts @@ -1,7 +1,6 @@ import { translit } from './ru2en' import { Author, Topic } from '../graphql/types.gen' - -type SearchData = Array +import { isAuthor } from './isAuthor' const prepareQuery = (searchQuery, lang) => { const q = searchQuery.toLowerCase() @@ -14,9 +13,16 @@ const stringMatches = (str, q, lang) => { return preparedStr.split(' ').some((word) => word.startsWith(q)) } -export const dummyFilter = (data: SearchData, searchQuery: string, lang: 'ru' | 'en'): SearchData => { +export const dummyFilter = ( + data: T[], + searchQuery: string, + lang: 'ru' | 'en' +): T[] => { const q = prepareQuery(searchQuery, lang) - if (q.length === 0) return data + + if (q.length === 0) { + return data + } return data.filter((item) => { const slugMatches = item.slug && item.slug.split('-').some((w) => w.startsWith(q)) @@ -26,9 +32,10 @@ export const dummyFilter = (data: SearchData, searchQuery: string, lang: 'ru' | return stringMatches(item.title, q, lang) } - if ('name' in item) { + if (isAuthor(item)) { return stringMatches(item.name, q, lang) || (item.bio && stringMatches(item.bio, q, lang)) } + // If it does not match any of the 'slug', 'title', 'name' , 'bio' fields // current element should not be included in the filtered array return false diff --git a/src/utils/formatDateTime.ts b/src/utils/formatDateTime.ts deleted file mode 100644 index 24f00a37..00000000 --- a/src/utils/formatDateTime.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Accessor, createMemo } from 'solid-js' -import { useLocalize } from '../context/localize' - -// unix timestamp in seconds -const formattedTime = (time: number): Accessor => { - // FIXME: maybe it's better to move it from here - const { lang } = useLocalize() - - return createMemo(() => { - return new Date(time).toLocaleTimeString(lang(), { - hour: 'numeric', - minute: 'numeric' - }) - }) -} - -export default formattedTime diff --git a/src/utils/index.ts b/src/utils/index.ts deleted file mode 100644 index 2da4dc2c..00000000 --- a/src/utils/index.ts +++ /dev/null @@ -1,83 +0,0 @@ -export const reflow = () => document.body.clientWidth - -export const unique = (v) => { - const s = new Set(v) - return [...s] -} - -export const preventSmoothScrollOnTabbing = () => { - document.addEventListener('keydown', (e) => { - if (e.key !== 'Tab') return - - document.documentElement.style.scrollBehavior = '' - - setTimeout(() => { - document.documentElement.style.scrollBehavior = 'smooth' - }) - }) -} - -export const capitalize = (originalString: string, firstonly = false) => { - const s = originalString.trim() - return firstonly - ? s.charAt(0).toUpperCase() + s.slice(1) - : s - .split(' ') - .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) - .join(' ') -} - -export const plural = (amount: number, w: string[]) => { - try { - const a = amount.toString() - const x = Number.parseInt(a.at(-1)) - const xx = Number.parseInt(a.at(-2) + a.at(-1)) - - if (xx > 5 && xx < 20) return w[0] - - if (x === 1) return w[1] - - if (x > 1 && x < 5) return w[2] - } catch (error) { - console.error('[utils] plural error', error) - } - - return w[0] -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const shuffle = (items: any[]) => { - const cached = [...items] - let temp - let i = cached.length - let rand - - while (--i) { - rand = Math.floor(i * Math.random()) - temp = cached[rand] - cached[rand] = cached[i] - cached[i] = temp - } - - return cached -} - -export const snake2camel = (s: string) => - s - .split(/(?=[A-Z])/) - .join('-') - .toLowerCase() - -export const formatDate = (date: Date, options: Intl.DateTimeFormatOptions = {}) => { - const opts = Object.assign( - {}, - { - month: 'long', - day: 'numeric', - year: 'numeric' - }, - options - ) - - return date.toLocaleDateString('ru', opts).replace(' г.', '') -}