diff --git a/package.json b/package.json index 45037bb..38be908 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,14 @@ "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@lexical/code": "^0.37.0", + "@lexical/link": "^0.37.0", + "@lexical/list": "^0.37.0", + "@lexical/react": "^0.37.0", + "@lexical/rich-text": "^0.37.0", + "@lexical/selection": "^0.37.0", + "@lexical/table": "^0.37.0", + "@lexical/utils": "^0.37.0", "@reduxjs/toolkit": "^2.2.2", "date-fns": "^3.6.0", "eslint-config-next": "^14.1.4", @@ -23,8 +31,10 @@ "i18next": "^23.11.5", "i18next-browser-languagedetector": "^7.2.1", "i18next-resources-to-backend": "^1.2.1", + "lexical": "^0.37.0", "modern-normalize": "^2.0.0", "next": "^14.1.4", + "obra-icons-react": "^1.23.1", "react": "^18.3.1", "react-i18next": "^14.1.2", "react-redux": "^9.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d3b1ca1..5bcb8fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,30 @@ importers: '@dnd-kit/utilities': specifier: ^3.2.2 version: 3.2.2(react@18.3.1) + '@lexical/code': + specifier: ^0.37.0 + version: 0.37.0 + '@lexical/link': + specifier: ^0.37.0 + version: 0.37.0 + '@lexical/list': + specifier: ^0.37.0 + version: 0.37.0 + '@lexical/react': + specifier: ^0.37.0 + version: 0.37.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(yjs@13.6.31) + '@lexical/rich-text': + specifier: ^0.37.0 + version: 0.37.0 + '@lexical/selection': + specifier: ^0.37.0 + version: 0.37.0 + '@lexical/table': + specifier: ^0.37.0 + version: 0.37.0 + '@lexical/utils': + specifier: ^0.37.0 + version: 0.37.0 '@reduxjs/toolkit': specifier: ^2.2.2 version: 2.2.5(react-redux@9.1.2(@types/react@18.3.3)(react@18.3.1)(redux@5.0.1))(react@18.3.1) @@ -47,12 +71,18 @@ importers: i18next-resources-to-backend: specifier: ^1.2.1 version: 1.2.1 + lexical: + specifier: ^0.37.0 + version: 0.37.0 modern-normalize: specifier: ^2.0.0 version: 2.0.0 next: specifier: ^14.1.4 version: 14.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.5) + obra-icons-react: + specifier: ^1.23.1 + version: 1.24.0(react@18.3.1) react: specifier: ^18.3.1 version: 18.3.1 @@ -107,7 +137,7 @@ importers: version: 8.57.0 eslint-config-love: specifier: ^43.1.0 - version: 43.1.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))(eslint-plugin-n@16.6.2(eslint@8.57.0))(eslint-plugin-promise@6.2.0(eslint@8.57.0))(eslint@8.57.0)(typescript@5.4.5) + version: 43.1.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint-plugin-n@16.6.2(eslint@8.57.0))(eslint-plugin-promise@6.2.0(eslint@8.57.0))(eslint@8.57.0)(typescript@5.4.5) eslint-plugin-import: specifier: ^2.29.1 version: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) @@ -179,6 +209,27 @@ packages: resolution: {integrity: sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/react@0.27.19': + resolution: {integrity: sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==} + peerDependencies: + react: '>=17.0.0' + react-dom: '>=17.0.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@humanwhocodes/config-array@0.11.14': resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} @@ -194,6 +245,80 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@lexical/clipboard@0.37.0': + resolution: {integrity: sha512-hRwASFX/ilaI5r8YOcZuQgONFshRgCPfdxfofNL7uruSFYAO6LkUhsjzZwUgf0DbmCJmbBADFw15FSthgCUhGA==} + + '@lexical/code@0.37.0': + resolution: {integrity: sha512-ZXA4j/S8yLrxjrTnEp39VeDMp4Rd8bLYUlT4Buy1MQlS1WafxOiMhNQJG7k0BP/pO96YPkAebpA81ATKJL0IgA==} + + '@lexical/devtools-core@0.37.0': + resolution: {integrity: sha512-iOR+aKLJR92nKYcEOW3K/bgjTN7dJIRC/OM4OvzigU0Xygxped0lXV6UmkYBp0eoqOOwckB8+rZWZszj9lKA8Q==} + peerDependencies: + react: '>=17.x' + react-dom: '>=17.x' + + '@lexical/dragon@0.37.0': + resolution: {integrity: sha512-iC4OKivEPtt7cGVSwZylLfz5T7Oqr9q9EOosS6E/byMyoqwkYWGjXn/qFiwIv1Xo3+G19vhfChi/+ZcYLXpHPw==} + + '@lexical/extension@0.37.0': + resolution: {integrity: sha512-Z58f2tIdz9bn8gltUu5cVg37qROGha38dUZv20gI2GeNugXAkoPzJYEcxlI1D/26tkevJ/7VaFUr9PTk+iKmaA==} + + '@lexical/hashtag@0.37.0': + resolution: {integrity: sha512-DHoDpiokJRBu+GnC0qQH529hamn9YNjL7vzzkTAeEMKsT9+4O848Cq6F2GJn8QjQToySlkVZW3mkh76uf/XLfg==} + + '@lexical/history@0.37.0': + resolution: {integrity: sha512-QKkrWCw4bsn/ZeLIkMVIpbtWKPhMYeax1nE7erHqTEwE52QR6pmZsZBgGSQDO73Ae29vahOmqlN7+ZJFvTKMVA==} + + '@lexical/html@0.37.0': + resolution: {integrity: sha512-oTsBc45eL8/lmF7fqGR+UCjrJYP04gumzf5nk4TczrxWL2pM4GIMLLKG1mpQI2H1MDiRLzq3T/xdI7Gh74z7Zw==} + + '@lexical/link@0.37.0': + resolution: {integrity: sha512-gglkjE99tKYnGAxQbrUq9TcaVKBQhidXhgPPbVw3x1Fba9biMafkbSJhE/7/pzQTPoQBAIl0w7DOUWmBOv+JbQ==} + + '@lexical/list@0.37.0': + resolution: {integrity: sha512-AOC6yAA3mfNvJKbwo+kvAbPJI+13yF2ISA65vbA578CugvJ08zIVgM+pSzxquGhD0ioJY3cXVW7+gdkCP1qu5g==} + + '@lexical/mark@0.37.0': + resolution: {integrity: sha512-ncjaL6kNHVioekx6vI5oJRDExFDJLbnXT7AdMnUv2LE3sxn/ea+JsZO/MDI4Ygmxq+lGtgZvbBDER8Yh/+5jdA==} + + '@lexical/markdown@0.37.0': + resolution: {integrity: sha512-pcLMpxWkSxU2QaN2GLA3hNy4lV2A8sJOvb5YEkcsFEcVvFFbAz7lxgyKVYtDboRCW1eZFks1UGGuJEogLeEFdg==} + + '@lexical/offset@0.37.0': + resolution: {integrity: sha512-q9Ckftfhb+VepJQeaClOYzpuV+WqWWGkSUuoexV4zjAm/HVjOie9lrNF4NkhQe5crnIBXI5zOofhuEfiCQWsbQ==} + + '@lexical/overflow@0.37.0': + resolution: {integrity: sha512-GC5qoQJQzaofCq1eMMvv9wIGMAbpFbFwny5BKA1C2Nmn+/2bi6v+7qlHwiBlbSVqfLVPvT4nYdrmNdnKoE0jZg==} + + '@lexical/plain-text@0.37.0': + resolution: {integrity: sha512-4IxG9Tr0NnQ+clN1eoXfe2W8JTgw0xtPMzqvHP2IaO7RILUE6H8VFSOdhAOI0dHrjlXRMUS3I2Fhqr2ZRq8kdQ==} + + '@lexical/react@0.37.0': + resolution: {integrity: sha512-PGIGmI5xDSAguqpAStd+89TfWsi6hs/R4a3hQAyNwXXDEt4anUFJic4Qet4YftybLGajP3vMvouLE5hrkmBihg==} + peerDependencies: + react: '>=17.x' + react-dom: '>=17.x' + + '@lexical/rich-text@0.37.0': + resolution: {integrity: sha512-A9i5Es/RrZv71tB6dDSyd4TYdbkn/+oUrUdTwnWa+B8EZW26q0h+wgxCGwPtTU7ho4JNP9HOot+EIhe2DbyaYg==} + + '@lexical/selection@0.37.0': + resolution: {integrity: sha512-Lix1s2r71jHfsTEs4q/YqK2s3uXKOnyA3fd1VDMWysO+bZzRwEO5+qyDvENZ0WrXSDCnlibNFV1HttWX9/zqyw==} + + '@lexical/table@0.37.0': + resolution: {integrity: sha512-g7S8ml8kIujEDLWlzYKETgPCQ2U9oeWqdytRuHjHGi/rjAAGHSej5IRqTPIMxNP3VVQHnBoQ+Y9hBtjiuddhgQ==} + + '@lexical/text@0.37.0': + resolution: {integrity: sha512-qByNjHp88mlUWHxfYutH4vhSs3nzfCGHKsf/MqUMOC8K7Kmp0V1NK6cOW1sgsHpzkovfpgcNOGDzZxTNCFgHtg==} + + '@lexical/utils@0.37.0': + resolution: {integrity: sha512-CFp4diY/kR5RqhzQSl/7SwsMod1sgLpI1FBifcOuJ6L/S6YywGpEB4B7aV5zqW21A/jU2T+2NZtxSUn6S+9gMg==} + + '@lexical/yjs@0.37.0': + resolution: {integrity: sha512-7UjHvXDd+Is/qTdNkpQ/K04Zduh2uh7UTlSWbMiqwbQh8VRJNXXgcH8iK0TXLwc7M3VgVk+FlnNApNvcReKB6g==} + peerDependencies: + yjs: '>=13.5.22' + '@next/env@14.2.4': resolution: {integrity: sha512-3EtkY5VDkuV2+lNmKlbkibIJxcO4oIHEhBWne6PaAp+76J9KoSsGvNikp6ivzAT8dhhBMYrm6op2pS1ApG0Hzg==} @@ -217,24 +342,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@14.2.4': resolution: {integrity: sha512-Alv8/XGSs/ytwQcbCHwze1HmiIkIVhDHYLjczSVrf0Wi2MvKn/blt7+S6FJitj3yTlMwMxII1gIJ9WepI4aZ/A==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@14.2.4': resolution: {integrity: sha512-ze0ShQDBPCqxLImzw4sCdfnB3lRmN3qGMB2GWDRlq5Wqy4G36pxtNOo2usu/Nm9+V2Rh/QQnrRc2l94kYFXO6Q==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@14.2.4': resolution: {integrity: sha512-8dwC0UJoc6fC7PX70csdaznVMNr16hQrTDAMPvLPloazlcaWfdPogq+UpZX6Drqb1OBlwowz8iG7WR0Tzk/diQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@14.2.4': resolution: {integrity: sha512-jxyg67NbEWkDyvM+O8UDbPAyYRZqGLQDTPwvrBBeOSyVWW/jFQkQKQ70JDqDSYg1ZDdl+E3nkbFbq8xM8E9x8A==} @@ -274,6 +403,9 @@ packages: resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@preact/signals-core@1.14.2': + resolution: {integrity: sha512-RZHdBj9ZF4n40Rp4jS052EHHjBWf96P9oNdXPfhQTovCuWY9iQn3Gq+gOTJSgBO9A/JBuPfMOWsSX/lIU9Pc/A==} + '@reduxjs/toolkit@2.2.5': resolution: {integrity: sha512-aeFA/s5NCG7NoJe/MhmwREJxRkDs0ZaSqt0MxhWUrwCf1UQXpwR87RROJEql0uAkLI6U7snBOYOcKw83ew3FPg==} peerDependencies: @@ -1211,6 +1343,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isomorphic.js@0.2.5: + resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + iterator.prototype@1.1.2: resolution: {integrity: sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==} @@ -1256,6 +1391,14 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lexical@0.37.0: + resolution: {integrity: sha512-r5VJR2TioQPAsZATfktnJFrGIiy6gjQN8b/+0a2u1d7/QTH7lhbB7byhGSvcq1iaa1TV/xcf/pFV55a5V5hTDQ==} + + lib0@0.2.117: + resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==} + engines: {node: '>=16'} + hasBin: true + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -1389,6 +1532,11 @@ packages: resolution: {integrity: sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==} engines: {node: '>= 0.4'} + obra-icons-react@1.24.0: + resolution: {integrity: sha512-kxdjmglRQOanKDCbBzBd14+cAUrBTSgA3spt9YNHPnT8OWjfTWx9i0DqvZzTO6v8QQM69crSJynjCk2MW52TTA==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -1464,6 +1612,10 @@ packages: engines: {node: '>=14'} hasBin: true + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -1489,6 +1641,11 @@ packages: peerDependencies: react: ^18.3.1 + react-error-boundary@6.1.2: + resolution: {integrity: sha512-3DpCr5HVdZ0caUjYE/kIHBEJN0mNP3ZCgf16c48uJ5TbWjorKVp+YG8W3XqlJ7vJAVNw6wNIImyPXmFydwmyng==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-i18next@14.1.2: resolution: {integrity: sha512-FSIcJy6oauJbGEXfhUgVeLzvWBhIBIS+/9c6Lj4niwKZyGaGb4V4vUbATXSlsHJDXXB+ociNxqFNiFuV1gmoqg==} peerDependencies: @@ -1740,6 +1897,9 @@ packages: resolution: {integrity: sha512-HwOKAP7Wc5aRGYdKH+dw0PRRpbO841v2DENBtjnR5HFWoiNByAl7vrx3p0G/rCyYXQsrxqtX48TImFtPcIHSpQ==} engines: {node: ^14.18.0 || >=16.0.0} + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + tapable@2.2.1: resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} engines: {node: '>=6'} @@ -1867,6 +2027,10 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yjs@13.6.31: + resolution: {integrity: sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -1932,6 +2096,31 @@ snapshots: '@eslint/js@8.57.0': {} + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@floating-ui/react@0.27.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@floating-ui/utils': 0.2.11 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tabbable: 6.4.0 + + '@floating-ui/utils@0.2.11': {} + '@humanwhocodes/config-array@0.11.14': dependencies: '@humanwhocodes/object-schema': 2.0.3 @@ -1953,6 +2142,167 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@lexical/clipboard@0.37.0': + dependencies: + '@lexical/html': 0.37.0 + '@lexical/list': 0.37.0 + '@lexical/selection': 0.37.0 + '@lexical/utils': 0.37.0 + lexical: 0.37.0 + + '@lexical/code@0.37.0': + dependencies: + '@lexical/utils': 0.37.0 + lexical: 0.37.0 + prismjs: 1.30.0 + + '@lexical/devtools-core@0.37.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@lexical/html': 0.37.0 + '@lexical/link': 0.37.0 + '@lexical/mark': 0.37.0 + '@lexical/table': 0.37.0 + '@lexical/utils': 0.37.0 + lexical: 0.37.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@lexical/dragon@0.37.0': + dependencies: + '@lexical/extension': 0.37.0 + lexical: 0.37.0 + + '@lexical/extension@0.37.0': + dependencies: + '@lexical/utils': 0.37.0 + '@preact/signals-core': 1.14.2 + lexical: 0.37.0 + + '@lexical/hashtag@0.37.0': + dependencies: + '@lexical/text': 0.37.0 + '@lexical/utils': 0.37.0 + lexical: 0.37.0 + + '@lexical/history@0.37.0': + dependencies: + '@lexical/extension': 0.37.0 + '@lexical/utils': 0.37.0 + lexical: 0.37.0 + + '@lexical/html@0.37.0': + dependencies: + '@lexical/selection': 0.37.0 + '@lexical/utils': 0.37.0 + lexical: 0.37.0 + + '@lexical/link@0.37.0': + dependencies: + '@lexical/extension': 0.37.0 + '@lexical/utils': 0.37.0 + lexical: 0.37.0 + + '@lexical/list@0.37.0': + dependencies: + '@lexical/extension': 0.37.0 + '@lexical/selection': 0.37.0 + '@lexical/utils': 0.37.0 + lexical: 0.37.0 + + '@lexical/mark@0.37.0': + dependencies: + '@lexical/utils': 0.37.0 + lexical: 0.37.0 + + '@lexical/markdown@0.37.0': + dependencies: + '@lexical/code': 0.37.0 + '@lexical/link': 0.37.0 + '@lexical/list': 0.37.0 + '@lexical/rich-text': 0.37.0 + '@lexical/text': 0.37.0 + '@lexical/utils': 0.37.0 + lexical: 0.37.0 + + '@lexical/offset@0.37.0': + dependencies: + lexical: 0.37.0 + + '@lexical/overflow@0.37.0': + dependencies: + lexical: 0.37.0 + + '@lexical/plain-text@0.37.0': + dependencies: + '@lexical/clipboard': 0.37.0 + '@lexical/dragon': 0.37.0 + '@lexical/selection': 0.37.0 + '@lexical/utils': 0.37.0 + lexical: 0.37.0 + + '@lexical/react@0.37.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(yjs@13.6.31)': + dependencies: + '@floating-ui/react': 0.27.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@lexical/devtools-core': 0.37.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@lexical/dragon': 0.37.0 + '@lexical/extension': 0.37.0 + '@lexical/hashtag': 0.37.0 + '@lexical/history': 0.37.0 + '@lexical/link': 0.37.0 + '@lexical/list': 0.37.0 + '@lexical/mark': 0.37.0 + '@lexical/markdown': 0.37.0 + '@lexical/overflow': 0.37.0 + '@lexical/plain-text': 0.37.0 + '@lexical/rich-text': 0.37.0 + '@lexical/table': 0.37.0 + '@lexical/text': 0.37.0 + '@lexical/utils': 0.37.0 + '@lexical/yjs': 0.37.0(yjs@13.6.31) + lexical: 0.37.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-error-boundary: 6.1.2(react@18.3.1) + transitivePeerDependencies: + - yjs + + '@lexical/rich-text@0.37.0': + dependencies: + '@lexical/clipboard': 0.37.0 + '@lexical/dragon': 0.37.0 + '@lexical/selection': 0.37.0 + '@lexical/utils': 0.37.0 + lexical: 0.37.0 + + '@lexical/selection@0.37.0': + dependencies: + lexical: 0.37.0 + + '@lexical/table@0.37.0': + dependencies: + '@lexical/clipboard': 0.37.0 + '@lexical/extension': 0.37.0 + '@lexical/utils': 0.37.0 + lexical: 0.37.0 + + '@lexical/text@0.37.0': + dependencies: + lexical: 0.37.0 + + '@lexical/utils@0.37.0': + dependencies: + '@lexical/list': 0.37.0 + '@lexical/selection': 0.37.0 + '@lexical/table': 0.37.0 + lexical: 0.37.0 + + '@lexical/yjs@0.37.0(yjs@13.6.31)': + dependencies: + '@lexical/offset': 0.37.0 + '@lexical/selection': 0.37.0 + lexical: 0.37.0 + yjs: 13.6.31 + '@next/env@14.2.4': {} '@next/eslint-plugin-next@14.2.4': @@ -2003,6 +2353,8 @@ snapshots: '@pkgr/core@0.1.1': {} + '@preact/signals-core@1.14.2': {} + '@reduxjs/toolkit@2.2.5(react-redux@9.1.2(@types/react@18.3.3)(react@18.3.1)(redux@5.0.1))(react@18.3.1)': dependencies: immer: 10.1.1 @@ -2584,12 +2936,12 @@ snapshots: eslint: 8.57.0 semver: 7.6.0 - eslint-config-love@43.1.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))(eslint-plugin-n@16.6.2(eslint@8.57.0))(eslint-plugin-promise@6.2.0(eslint@8.57.0))(eslint@8.57.0)(typescript@5.4.5): + eslint-config-love@43.1.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint-plugin-n@16.6.2(eslint@8.57.0))(eslint-plugin-promise@6.2.0(eslint@8.57.0))(eslint@8.57.0)(typescript@5.4.5): dependencies: '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5) '@typescript-eslint/parser': 6.21.0(eslint@8.57.0)(typescript@5.4.5) eslint: 8.57.0 - eslint-config-standard: 17.1.0(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))(eslint-plugin-n@16.6.2(eslint@8.57.0))(eslint-plugin-promise@6.2.0(eslint@8.57.0))(eslint@8.57.0) + eslint-config-standard: 17.1.0(eslint-plugin-import@2.29.1)(eslint-plugin-n@16.6.2(eslint@8.57.0))(eslint-plugin-promise@6.2.0(eslint@8.57.0))(eslint@8.57.0) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) eslint-plugin-n: 16.6.2(eslint@8.57.0) eslint-plugin-promise: 6.2.0(eslint@8.57.0) @@ -2619,7 +2971,7 @@ snapshots: dependencies: eslint: 8.57.0 - eslint-config-standard@17.1.0(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))(eslint-plugin-n@16.6.2(eslint@8.57.0))(eslint-plugin-promise@6.2.0(eslint@8.57.0))(eslint@8.57.0): + eslint-config-standard@17.1.0(eslint-plugin-import@2.29.1)(eslint-plugin-n@16.6.2(eslint@8.57.0))(eslint-plugin-promise@6.2.0(eslint@8.57.0))(eslint@8.57.0): dependencies: eslint: 8.57.0 eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) @@ -2639,7 +2991,7 @@ snapshots: debug: 4.3.4 enhanced-resolve: 5.16.0 eslint: 8.57.0 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) fast-glob: 3.3.2 get-tsconfig: 4.7.3 @@ -2651,7 +3003,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0): + eslint-module-utils@2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0): dependencies: debug: 3.2.7 optionalDependencies: @@ -2679,7 +3031,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) hasown: 2.0.2 is-core-module: 2.13.1 is-glob: 4.0.3 @@ -3161,6 +3513,8 @@ snapshots: isexe@2.0.0: {} + isomorphic.js@0.2.5: {} + iterator.prototype@1.1.2: dependencies: define-properties: 1.2.1 @@ -3213,6 +3567,12 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lexical@0.37.0: {} + + lib0@0.2.117: + dependencies: + isomorphic.js: 0.2.5 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -3342,6 +3702,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.0.0 + obra-icons-react@1.24.0(react@18.3.1): + dependencies: + react: 18.3.1 + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -3417,6 +3781,8 @@ snapshots: prettier@3.2.5: {} + prismjs@1.30.0: {} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -3447,6 +3813,10 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-error-boundary@6.1.2(react@18.3.1): + dependencies: + react: 18.3.1 + react-i18next@14.1.2(i18next@23.11.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@babel/runtime': 7.24.5 @@ -3726,6 +4096,8 @@ snapshots: '@pkgr/core': 0.1.1 tslib: 2.6.2 + tabbable@6.4.0: {} + tapable@2.2.1: {} tar-fs@2.1.1: @@ -3901,4 +4273,8 @@ snapshots: yallist@4.0.0: {} + yjs@13.6.31: + dependencies: + lib0: 0.2.117 + yocto-queue@0.1.0: {} diff --git a/public/locales/fr/assos.json.ts b/public/locales/fr/assos.json.ts index 7f61546..32b683d 100644 --- a/public/locales/fr/assos.json.ts +++ b/public/locales/fr/assos.json.ts @@ -5,6 +5,10 @@ export default { browser: 'UTT Travail', 'filter.search': 'Recherche dans le guide des assos', 'filter.search.title': 'Recherche dans le guide des assos', + 'infos.edit': 'Modifier', + 'infos.edit.save': 'Enregistrer', + 'infos.edit.description.placeholder': 'Notre association est géniale parce que...', + 'infos.description.empty': "Cette association n'a pas encore de description.", 'member.list.title': 'Membres', 'member.since': 'Depuis ', 'member.old.from': 'Entre ', @@ -27,11 +31,11 @@ export default { 'member.add.label.role': 'Rôle', 'member.add.label.permissions': 'Permissions', 'member.add.label.endAt': "Date de fin d'adhésion", + 'member.edit.no-permission': 'Permission insuffisante pour modifier les membres', 'member.add.submit': 'Ajouter', 'member.edit.title': 'Modifier un membre', 'member.edit.label.role': 'Rôle', 'member.edit.label.permissions': 'Permissions', 'member.edit.label.endAt': "Date de fin d'adhésion", 'member.edit.submit': 'Modifier', - 'no.permission.edit.member': 'Permission insuffisante pour modifier les membres', } as const; diff --git a/public/locales/fr/common.json.ts b/public/locales/fr/common.json.ts index bd9c32e..0fd03c8 100644 --- a/public/locales/fr/common.json.ts +++ b/public/locales/fr/common.json.ts @@ -32,7 +32,10 @@ export default { 'navbar.userBrowser': 'Trombinoscope', or: 'Ou', results: 'résultats', + confirm: 'Confirmer', '404': 'Page not found', '404.description': "La page demandée n'existe pas ou n'est plus disponible.", - confirm: 'Confirmer', + 'rte.dnd.drop': "Déposez le fichier pour l'importer", + 'rte.toolbar.uploadImage': "Cliquez pour sélectionner l'image", + 'ui.profilepicture.upload': 'Télécharger une image', } as const; diff --git a/src/api/api.ts b/src/api/api.ts index e7a45a0..8d5a8c7 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -4,6 +4,11 @@ import { useNotFound } from '@/module/pageSettings'; import { toast } from 'react-toastify'; import { ApiError } from '@/api/api.interface'; +export const buildApiUrl = (path: string, version = apiVersion) => + `${apiUrl.slice(-1) === '/' ? apiUrl.slice(0, -1) : apiUrl}/${version}/${ + path.slice(0, 1) === '/' ? path.slice(1) : path + }`; + /** * The type of error that can be produced while making a request to the API. * Note that these errors are not errors that the API can return, but rather errors that can happen while making a request / interpreting the result. @@ -94,7 +99,6 @@ export class ResponseHandler = { fall { private readonly handlers = { fallback: () => undefined } as R; private readonly promise: Promise any ? R[keyof R] : never>>; - // public readonly abortController: AbortController; constructor( rawResponse: Promise>, @@ -203,6 +207,7 @@ function formatResponse(rawResponse: RawResponseType): T { * @param version The version of the API to use : v1, v2, ... * @param isFile If what we are sending/fetching is a file. * @param applicationId Id of the application making the request. + * @param forceCache If true, the result of the request is cached. Otherwise, it won't. * @param abortController AbortController to add to the request. */ async function internalRequestAPI( @@ -213,6 +218,7 @@ async function internalRequestAPI( version: string, isFile: true, applicationId: string, + forceCache: boolean, abortController: AbortController, ): Promise>; async function internalRequestAPI( @@ -223,6 +229,7 @@ async function internalRequestAPI( version: string, isFile: boolean, applicationId: string, + forceCache: boolean, abortController: AbortController, ): Promise>; async function internalRequestAPI( @@ -233,6 +240,7 @@ async function internalRequestAPI( version: string, isFile: boolean, applicationId: string, + forceCache: boolean, abortController: AbortController, ): Promise> { // Generate headers @@ -248,21 +256,16 @@ async function internalRequestAPI( try { // Make the request - const response = await fetch( - `${apiUrl.slice(-1) === '/' ? apiUrl.slice(0, -1) : apiUrl}/${version}/${ - route.slice(0, 1) === '/' ? route.slice(1) : route - }`, - { - method, - headers, - body: (method === 'GET' || method === 'DELETE' ? undefined : isFile ? body : JSON.stringify(body)) as - | BodyInit - | null - | undefined, - cache: 'no-cache', - signal: abortController.signal, - }, - ); + const response = await fetch(buildApiUrl(route, version), { + method, + headers, + body: (method === 'GET' || method === 'DELETE' ? undefined : isFile ? body : JSON.stringify(body)) as + | BodyInit + | null + | undefined, + cache: forceCache ? 'force-cache' : 'no-cache', + signal: abortController.signal, + }); if (response.status === StatusCodes.NO_CONTENT) { return { code: response.status, body: null as ResponseType }; @@ -316,13 +319,13 @@ function requestAPI( method: 'GET', route: string, body: RequestType | null, - params: { timeoutMillis?: number; version?: string; isFile: true; applicationId?: string }, + params: { timeoutMillis?: number; version?: string; isFile: true; applicationId?: string; forceCache?: boolean }, ): ResponseHandler; function requestAPI( method: string, route: string, body: RequestType | null, - params: { timeoutMillis?: number; version?: string; isFile?: boolean; applicationId?: string }, + params: { timeoutMillis?: number; version?: string; isFile?: boolean; applicationId?: string; forceCache?: boolean }, ): ResponseHandler; function requestAPI( method: string, @@ -333,11 +336,12 @@ function requestAPI( version = apiVersion, isFile = false, applicationId = etuuttWebApplicationId, - }: { timeoutMillis?: number; version?: string; isFile?: boolean; applicationId?: string } = {}, + forceCache = false, + }: { timeoutMillis?: number; version?: string; isFile?: boolean; applicationId?: string; forceCache?: boolean } = {}, ): ResponseHandler { const abortController = new AbortController(); return new ResponseHandler( - internalRequestAPI(method, route, body, timeoutMillis, version, isFile, applicationId, abortController), + internalRequestAPI(method, route, body, timeoutMillis, version, isFile, applicationId, forceCache, abortController), abortController, ); } @@ -360,7 +364,7 @@ export function useAPI(): API { applyDefaultHandler(requestAPI('GET', route, null, { ...options, isFile: true }), setNotFound), get: ( route: string, - options: { timeoutMillis?: number; version?: string; applicationId?: string } = {}, + options: { timeoutMillis?: number; version?: string; forceCache?: boolean; applicationId?: string } = {}, ) => applyDefaultHandler( requestAPI('GET', route, null, { ...options, isFile: false }), @@ -389,11 +393,11 @@ export function useAPI(): API { export interface API { getFile( route: string, - options?: { timeoutMillis?: number; version?: string; applicationId?: string }, + options?: { timeoutMillis?: number; version?: string; applicationId?: string; forceCache?: boolean }, ): DefaultResponseHandlerType; get( route: string, - options?: { timeoutMillis?: number; version?: string; applicationId?: string }, + options?: { timeoutMillis?: number; version?: string; applicationId?: string; forceCache?: boolean }, ): DefaultResponseHandlerType; post( route: string, diff --git a/src/api/assos/asso.interface.ts b/src/api/assos/asso.interface.ts index 1c2019f..5922e7e 100644 --- a/src/api/assos/asso.interface.ts +++ b/src/api/assos/asso.interface.ts @@ -32,3 +32,25 @@ export interface Asso { }; }; } + +export interface AssoUpdateRequest { + name?: string; + description?: { + fr?: string; + en?: string; + de?: string; + es?: string; + zh?: string; + }; + descriptionShort?: { + fr?: string; + en?: string; + de?: string; + es?: string; + zh?: string; + }; + mail?: string; + phoneNumber?: string; + website?: string; + logo?: string; +} diff --git a/src/api/assos/fetchAsso.hook.ts b/src/api/assos/fetchAsso.hook.ts index f075ccf..08dbea1 100644 --- a/src/api/assos/fetchAsso.hook.ts +++ b/src/api/assos/fetchAsso.hook.ts @@ -11,7 +11,7 @@ export function useAsso(assoId: string): [Asso | null | undefined, (asso: Asso) .on('success', (body) => { setAsso(body); }) - .on('error', () => setAsso(null)); + .on('fallback', () => setAsso(null)); }, []); return [asso, setAsso]; } diff --git a/src/api/assos/updateAsso.ts b/src/api/assos/updateAsso.ts new file mode 100644 index 0000000..09f444c --- /dev/null +++ b/src/api/assos/updateAsso.ts @@ -0,0 +1,6 @@ +import { API } from '@/api/api'; +import { Asso, AssoUpdateRequest } from './asso.interface'; + +export async function updateAsso(api: API, assoId: string, options: AssoUpdateRequest): Promise { + return api.patch(`/assos/${assoId}`, options).toPromise(); +} diff --git a/src/api/media/media.interface.ts b/src/api/media/media.interface.ts new file mode 100644 index 0000000..30dee7c --- /dev/null +++ b/src/api/media/media.interface.ts @@ -0,0 +1,5 @@ +export interface UploadImageResponse { + id: string; + width: number; + height: number; +} diff --git a/src/api/media/uploadImage.ts b/src/api/media/uploadImage.ts new file mode 100644 index 0000000..0e6f2c5 --- /dev/null +++ b/src/api/media/uploadImage.ts @@ -0,0 +1,17 @@ +import { API } from '@/api/api'; +import { UploadImageResponse } from '@/api/media/media.interface'; + +export function uploadPublicImage( + api: API, + file: File, + { public_ = true, preset = null }: { public_?: boolean; preset?: 'AVATAR' | null }, +): Promise { + const formData = new FormData(); + formData.append('file', file); + return api + .post< + FormData, + UploadImageResponse + >(`/media/image?${new URLSearchParams({ public: public_ ? 'true' : 'false', ...(preset ? { preset } : {}) }).toString()}`, formData, { isFile: true }) + .toPromise(); +} diff --git a/src/app/(wrapper)/assos/page.tsx b/src/app/(wrapper)/assos/page.tsx index 99dc358..ff7c548 100644 --- a/src/app/(wrapper)/assos/page.tsx +++ b/src/app/(wrapper)/assos/page.tsx @@ -1,8 +1,8 @@ 'use client'; import styles from './style.module.scss'; +import { IconBook } from 'obra-icons-react'; import { createInputFilter } from '@/components/filteredSearch/InputFilter'; import FilteredSearch, { FiltersDataType, GenericFiltersType } from '@/components/filteredSearch/FilteredSearch'; -import Icons from '@/icons'; import { ResultsList } from '@/components/ResultsList'; import { useAppTranslation } from '@/lib/i18n'; import { useAssos } from '@/api/assos/searchAssos.hook'; @@ -22,7 +22,7 @@ type FilterNames = 'name'; */ const assoFilters = Object.freeze({ name: { - component: createInputFilter('assos:filter.search', 'assos:filter.search.title', Icons.Book), + component: createInputFilter('assos:filter.search', 'assos:filter.search.title', IconBook), parameterName: 'q', updateDelayed: true, }, // This one does not need a name as it will never be displayed diff --git a/src/app/(wrapper)/ues/[code]/Comments.tsx b/src/app/(wrapper)/ues/[code]/Comments.tsx index 561a96f..63456c5 100644 --- a/src/app/(wrapper)/ues/[code]/Comments.tsx +++ b/src/app/(wrapper)/ues/[code]/Comments.tsx @@ -1,7 +1,7 @@ import styles from './Comments.module.scss'; import useComments from '@/api/comment/fetchComments'; import { TFunction, useAppTranslation } from '@/lib/i18n'; -import Icons from '@/icons'; +import { IconChevronRight } from 'obra-icons-react'; import EditableText from '@/components/EditableText'; import Button from '@/components/UI/Button'; import { useConnectedUser } from '@/module/session'; @@ -99,7 +99,7 @@ function CommentFooter( {comment.answers.length === 0 ? t('ues:detailed.comments.conversation.see.empty') : t('ues:detailed.comments.conversation.see', { responseCount: comment.answers.length.toString() })} - + ); diff --git a/src/app/(wrapper)/ues/[code]/comments/[commentId]/page.tsx b/src/app/(wrapper)/ues/[code]/comments/[commentId]/page.tsx index 67a3665..8d06dc7 100644 --- a/src/app/(wrapper)/ues/[code]/comments/[commentId]/page.tsx +++ b/src/app/(wrapper)/ues/[code]/comments/[commentId]/page.tsx @@ -14,10 +14,7 @@ import { useAPI } from '@/api/api'; import { sendCommentReply } from '@/api/commentReply/sendCommentReply'; import Link from '@/components/UI/Link'; import Page from '@/components/utilities/Page'; -import Clock from '@/icons/Clock'; -import Comment from '@/icons/Comment'; -import Enter from '@/icons/Enter'; -import User from '@/icons/User'; +import { IconClock4Alt, IconComment, IconEnter, IconUser } from 'obra-icons-react'; import { useParams } from 'next/navigation'; function CommentEditorFooter(originalComment: string, onUpdate: (text: string) => void, t: TFunction) { @@ -67,18 +64,18 @@ export default function CommentDetailsPage() {
{!comment.isAnonymous && (
- + {comment.author.firstName} {comment.author.lastName}
)}
- + {t('ues:detailed.comments.semester', { semester: comment.semester.code })}
- +
{t('ues:detailed.comments.writtenDate', { date: comment.createdAt.toLocaleDateString() })}
{comment.updatedAt && ( @@ -92,7 +89,7 @@ export default function CommentDetailsPage() { {comment.answers.map((answer, i) => (
- +

diff --git a/src/app/(wrapper)/ues/[code]/page.tsx b/src/app/(wrapper)/ues/[code]/page.tsx index 9a7a28f..3362976 100644 --- a/src/app/(wrapper)/ues/[code]/page.tsx +++ b/src/app/(wrapper)/ues/[code]/page.tsx @@ -209,7 +209,7 @@ export default function UeDetailsPage() {

{t('ues:detailed.siepLink')}
{ue.ueofs[ueofIndex].code} diff --git a/src/app/(wrapper)/ues/page.tsx b/src/app/(wrapper)/ues/page.tsx index df8b7ac..4c75682 100644 --- a/src/app/(wrapper)/ues/page.tsx +++ b/src/app/(wrapper)/ues/page.tsx @@ -4,12 +4,12 @@ import { Branch } from '@/api/branch/branch.interface'; import { CreditCategory } from '@/api/credit/credit.interface'; import { useUEs } from '@/api/ue/search'; import FilteredSearch, { FiltersDataType, GenericFiltersType } from '@/components/filteredSearch/FilteredSearch'; +import { IconBook } from 'obra-icons-react'; import { createInputFilter } from '@/components/filteredSearch/InputFilter'; import { createSelectFilter, SelectFilter } from '@/components/filteredSearch/SelectFilter'; import { ResultsList } from '@/components/ResultsList'; import Tooltip from '@/components/UI/Tooltip'; import Page from '@/components/utilities/Page'; -import Icons from '@/icons'; import { useAppTranslation } from '@/lib/i18n'; import { useBranches, useCreditCategories } from '@/module/constantData'; import { useMemo } from 'react'; @@ -34,7 +34,7 @@ function useUeFilters(creditCategories: CreditCategory[] | null, branches: Branc return useMemo(() => { return Object.freeze({ name: { - component: createInputFilter('ues:filter.search', 'ues:filter.search.title', Icons.Book), + component: createInputFilter('ues:filter.search', 'ues:filter.search.title', IconBook), parameterName: 'q', updateDelayed: true, }, // This one does not need a name as it will never be displayed diff --git a/src/app/assos/[assoId]/page.tsx b/src/app/assos/[assoId]/page.tsx index 7968938..149f565 100644 --- a/src/app/assos/[assoId]/page.tsx +++ b/src/app/assos/[assoId]/page.tsx @@ -1,12 +1,21 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { + IconCall, + IconCheck, + IconClose, + IconEdit, + IconEmail, + IconExternalLink, + IconEye, + IconEyeOff, +} from 'obra-icons-react'; import { useParams } from 'next/navigation'; import styles from './style.module.scss'; import { useAsso } from '@/api/assos/fetchAsso.hook'; import Page from '@/components/utilities/Page'; import { useMembers } from '@/api/assos/fetchAssoMembers.hook'; -import Icons from '@/icons'; import Link from '@/components/UI/Link'; import { useAppTranslation } from '@/lib/i18n'; import Button from '@/components/UI/Button'; @@ -22,6 +31,11 @@ import { User } from '@/api/users/user.interface'; import { DataModalSchema, ModalStates, ModalForm, WindowOptions } from '@/components/UI/ModalForm'; import { AssoRole } from '@/components/assos/AssoRole'; import { c } from '@/utils'; +import LexicalTextEditor, { $makeJson } from '@/components/UI/LexicalTextEditor'; +import Input from '@/components/UI/Input'; +import Avatar from '@/components/UI/Avatar'; +import { AssoUpdateRequest } from '@/api/assos/asso.interface'; +import { updateAsso } from '@/api/assos/updateAsso'; type CreateAssoRoleModalFields = { name: 'string' }; type DeleteAssoRoleModalFields = { confirmation: 'string' }; @@ -46,31 +60,82 @@ type ExtraModalData = export default function AssoDetailPage() { const params = useParams<{ assoId: string }>(); const user = useConnectedUser(); - const [asso] = useAsso(params.assoId); + const [asso, setAsso] = useAsso(params.assoId); const [members, setMembers] = useMembers(params.assoId); const [permissions, setPermissions] = useState(new Set()); const [displayOldMembers, setDisplayOldMembers] = useState(false); + const [editInfosMode, setEditInfosMode] = useState(false); const [editMembersMode, setEditMembersMode] = useState(false); const [currentEditingRole, setCurrentEditingRole] = useState(null); const { t } = useAppTranslation(); - const [modalForm, setModalForm] = useState | null>(null); const [modalFormWindow, setModalFormWindow] = useState(null); const [extraModalData, setExtraModalData] = useState(null); + const [assoEdit, setAssoEdit] = useState({}); + const stateRef = useRef<(state: string) => void>(() => {}); const api = useAPI(); useEffect(() => { - const permissions = new Set( - members - .map((role) => - role.members.filter((member) => member.userId === user?.id).flatMap((member) => member.permissions), - ) - .flat(), - ); + if (!user) return; + const permissions = new Set(); + for (const role of members) { + if (role.isPresident) { + if (role.members.find((member) => member.userId === user.id)) { + permissions.add('manage_infos'); + permissions.add('manage_roles'); + permissions.add('manage_members'); + break; + } + } else { + const member = role.members.find((member) => member.userId === user.id); + if (member) { + member.permissions.forEach(permissions.add); + } + } + } setPermissions(permissions); }, [members, user]); + useEffect(() => { + if (asso?.description) stateRef.current(asso.description); + }, [asso]); + + // Asso edition zone + + const toggleAssoInfoEdition = () => { + if (!editInfosMode) { + setAssoEdit({ + name: asso?.name, + description: { fr: asso?.description }, + website: asso?.website, + mail: asso?.mail, + phoneNumber: asso?.phoneNumber, + logo: asso?.logo, + }); + } else { + const updatePayload: AssoUpdateRequest = {}; + if (asso?.name !== assoEdit.name) updatePayload.name = assoEdit.name; + if ( + asso?.description !== assoEdit.description?.fr && + $makeJson(asso?.description ?? '') !== assoEdit.description?.fr + ) + updatePayload.description = assoEdit.description; + if (asso?.website !== assoEdit.website) updatePayload.website = assoEdit.website; + if (asso?.mail !== assoEdit.mail) updatePayload.mail = assoEdit.mail; + if (asso?.phoneNumber !== assoEdit.phoneNumber) updatePayload.phoneNumber = assoEdit.phoneNumber; + if (assoEdit.logo && asso?.logo !== `/image/media/${assoEdit.logo}.webp`) updatePayload.logo = assoEdit.logo; + if (Object.keys(updatePayload).length > 0) + updateAsso(api, asso!.id, updatePayload).then((asso) => asso && setAsso(asso)); + setAssoEdit({}); + } + setEditInfosMode(!editInfosMode); + }; + + const updateAssoEdit = (update: AssoUpdateRequest) => { + setAssoEdit({ ...assoEdit, ...update }); + }; + const updateAssoRole = async (roleId: string, data: Partial<{ name: string; position: number }>) => { const role = members.find((r) => r.id === roleId); if (!role) return; @@ -90,6 +155,8 @@ export default function AssoDetailPage() { ); }; + // Asso role & members zone + const deleteAssoRole = async (roleId: string) => { const deletedRole = await deleteRole(api, asso!.id, roleId).toPromise(); setMembers(members.filter((role) => role.id !== deletedRole?.id)); @@ -248,34 +315,92 @@ export default function AssoDetailPage() { return (
- {`Logo + {permissions.has('manage_infos') && ( + + )} + updateAssoEdit({ logo: mediaId })} + />
-

{asso?.name}

-
{asso?.description}
+

+ {editInfosMode ? ( + updateAssoEdit({ name })} /> + ) : ( + asso?.name + )} +

+ {asso ? ( + updateAssoEdit({ description: { fr: state } })} + setStateRef={stateRef} + disabled={!editInfosMode} + /> + ) : ( + <> + {/* Loader */} +
+
+
+
+ + )}
- - -
{asso?.website?.replace(/^https?:\/\/(?:www\.)?/, '')}
- - - -
{asso?.mail}
- - - -
{asso?.phoneNumber}
- + {editInfosMode ? ( + <> + updateAssoEdit({ website })} + /> + updateAssoEdit({ mail })} /> + updateAssoEdit({ phoneNumber })} + /> + + ) : ( + <> + + +
{asso?.website?.replace(/^https?:\/\/(?:www\.)?/, '')}
+ + + +
{asso?.mail}
+ + + +
{asso?.phoneNumber}
+ + + )}
@@ -286,19 +411,18 @@ export default function AssoDetailPage() {
{permissions.has('manage_roles') && editMembersMode && ( )} {!!permissions.size && ( )} {!editMembersMode && ( )} diff --git a/src/app/assos/[assoId]/style.module.scss b/src/app/assos/[assoId]/style.module.scss index 42f1725..b2ce84c 100644 --- a/src/app/assos/[assoId]/style.module.scss +++ b/src/app/assos/[assoId]/style.module.scss @@ -3,63 +3,81 @@ .headerCard { background: white; - padding: 1.5em; + padding: 3ch; display: flex; flex-flow: row nowrap; - gap: 1em; - border-radius: 0.5em; - - & > img { - width: 6em; - height: 6em; - border-radius: 50%; - object-fit: cover; - display: flex; - text-align: center; - align-items: center; - justify-content: center; - overflow: hidden; - flex-shrink: 0; + position: relative; + gap: 2ch; + border-radius: 1ch; + + & > .edit { + position: absolute; + right: 3ch; + top: 3ch; + padding: 4px 8px !important; + border-radius: calc(1em + 4px) !important; + + svg { + margin-right: 0.5ch; + } + } + + & > .logo { + width: 125px; + height: 125px; + background: rgba($color: $ung-light-grey, $alpha: 0.2); + border: 2px solid rgba($color: $ung-light-grey, $alpha: 0.2); + color: $ung-dark-grey; + font-size: 6ch; } .details { display: flex; flex-flow: column nowrap; - gap: 1em; + gap: 2ch; justify-content: space-between; h1:empty, - div:empty { + & > div > :empty, + .actionRow div:empty { @extend .glimmer-animated; - width: 8em; + width: 16ch; height: 0.8em; margin-top: 0.2em; margin-bottom: 0.2em; &:nth-child(2) { - width: 24em; + width: 48ch; + } + + &:nth-child(3) { + width: 36ch; + } + + &:nth-child(4) { + width: 62ch; } } .actionRow { display: flex; flex-flow: row wrap; - gap: 1.5em; + gap: 3ch; svg + div { display: inline-block; vertical-align: super; - margin-left: 0.25em; + margin-left: 0.5ch; } :nth-child(1) div:empty { - width: 6em; + width: 12ch; } :nth-child(2) div:empty { - width: 9em; + width: 18ch; } :nth-child(3) div:empty { - width: 5em; + width: 10ch; } } } @@ -71,12 +89,12 @@ .membersCard { background: white; - margin-top: 1.5em; - padding: calc(1.5em - 2px); + margin-top: 3ch; + padding: calc(3ch - 2px); display: flex; flex-flow: column nowrap; - gap: 0.5em; - border-radius: 0.5em; + gap: 1ch; + border-radius: 1ch; border: 2px solid transparent; &.editMode { @@ -103,7 +121,7 @@ .actionRow { display: flex; flex-flow: row wrap; - gap: 0.5em; + gap: 1ch; align-items: center; } @@ -111,11 +129,15 @@ font-size: 0.8em; padding: 4px 8px; border-radius: calc(0.5em + 4px); + + svg { + margin-right: 0.5ch; + } } } > div { - padding: 0.5em 10px; + padding: 1ch 10px; } :has(> :not(path):nth-child(2):empty) { diff --git a/src/app/developers/application/page.tsx b/src/app/developers/application/page.tsx index e1f96f6..28f6e9e 100644 --- a/src/app/developers/application/page.tsx +++ b/src/app/developers/application/page.tsx @@ -1,17 +1,16 @@ 'use client'; import styles from './style.module.scss'; +import { IconCopy, IconDelete } from 'obra-icons-react'; import useApplications from '@/api/auth/applications/fetchApplications'; -import Trash from '@/icons/Trash'; import Input from '@/components/UI/Input'; import { useState } from 'react'; import Button from '@/components/UI/Button'; import createApplication from '@/api/auth/applications/createApplication'; import { useAPI } from '@/api/api'; import { updateApplicationToken } from '@/api/auth/applications/updateToken'; -import Icons from '@/icons'; -import Page from '@/components/utilities/Page'; import { useConnectedUser } from '@/module/session'; +import Page from '@/components/utilities/Page'; export default function ApplicationsPage() { const loggedIn = !!useConnectedUser(); @@ -42,7 +41,7 @@ export default function ApplicationsPage() { Générer un nouveau token
))} @@ -71,7 +70,7 @@ export default function ApplicationsPage() { {token}
diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 86ff2a5..7c263d1 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -2,11 +2,12 @@ import { useAppDispatch, useAppSelector } from '@/lib/hooks'; import styles from './Navbar.module.scss'; -import { FC, useState } from 'react'; +import { ForwardRefExoticComponent, useState } from 'react'; import { getMenu, setCollapsed, useCollapsed } from '@/module/navbar'; import Link from 'next/link'; import { type NotParameteredTranslationKey, useAppTranslation } from '@/lib/i18n'; import Icons from '@/icons'; +import { IconArrowLeft, IconLanguage, IconLogIn, IconLogOut, IconMenu } from 'obra-icons-react'; import { isLoggedIn, logout, useConnectedUser } from '@/module/session'; import Button from './UI/Button'; import { usePageSettings } from '@/module/pageSettings'; @@ -19,7 +20,7 @@ import { isDevEnv } from '@/utils/environment'; * This is an internal type that should not be used when developping features. * */ type MenuItemProperties = { - icon: FC; + icon: ForwardRefExoticComponent; name: Translate extends true ? NotParameteredTranslationKey : Translate extends false @@ -122,7 +123,7 @@ export default function Navbar() { - {'icon' in item ? (item as MenuItem).icon({}) : ''} + {'icon' in item && item.icon ? : ''} {item.translate ? t(item.name as NotParameteredTranslationKey) : item.name} @@ -136,7 +137,7 @@ export default function Navbar() {
toggleSelected([after, item.name].join(','))}> - {'icon' in item ? (item as MenuItem).icon({}) : ''} + {'icon' in item && item.icon ? : ''}
{item.translate ? t(item.name as NotParameteredTranslationKey) : item.name}
@@ -156,11 +157,11 @@ export default function Navbar() { EtuUTT
- +
- +
{/* NAVIGATION */} @@ -190,7 +191,7 @@ export default function Navbar() {
setLanguageSelectorOpen(!languageSelectorOpen)}> - +
@@ -218,7 +219,7 @@ export default function Navbar() {
- +
@@ -228,7 +229,7 @@ export default function Navbar() { {!loggedIn && (
- + Connexion
diff --git a/src/components/ResultsList.tsx b/src/components/ResultsList.tsx index dc0c487..66a72ec 100644 --- a/src/components/ResultsList.tsx +++ b/src/components/ResultsList.tsx @@ -2,7 +2,7 @@ import styles from './ResultsList.module.scss'; import { useRouter } from 'next/navigation'; import { FC, useEffect, useRef } from 'react'; import { useAppTranslation } from '@/lib/i18n'; -import Loader from '@/icons/Loader'; +import Icons from '@/icons'; export function ResultsList({ data, @@ -72,7 +72,7 @@ export function ResultsList({
{loading && (
- + {t('common:loading')}
)} diff --git a/src/components/StarRating.tsx b/src/components/StarRating.tsx index b3b653a..7bda1f3 100644 --- a/src/components/StarRating.tsx +++ b/src/components/StarRating.tsx @@ -1,6 +1,6 @@ import styles from './StarRating.module.scss'; import { CSSProperties } from 'react'; -import Star from '@/icons/Star'; +import { IconFavorite } from 'obra-icons-react'; export default function StarRating({ stars, @@ -19,7 +19,7 @@ export default function StarRating({ key={i} onClick={onClick && (() => onClick(i + 1))} className={`${styles.starWrapper} ${onClick ? styles.hoverEffect : ''}`}> - +
))}
diff --git a/src/components/UI/Avatar.module.scss b/src/components/UI/Avatar.module.scss new file mode 100644 index 0000000..98c2825 --- /dev/null +++ b/src/components/UI/Avatar.module.scss @@ -0,0 +1,68 @@ +@import '@/variables'; + +.avatar { + display: block; + position: relative; + width: 2em; + height: 2em; + border-radius: 50%; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + font-size: 1.2em; + background: $ung-light-blue; + color: $very-light-gray; + user-select: none; + border: 2px solid $ung-light-blue; + + img { + position: absolute; + object-fit: cover; + top: 0; + left: 0; + width: 100%; + height: 100%; + } +} + +.avatar:hover > .avatarEdit { + opacity: 1; +} + +.avatar > svg { + position: absolute; + width: 60%; + height: 60%; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + fill: $very-light-gray; + color: color-mix(in srgb, $ung-dark-grey 40%, $very-light-gray 60%); + pointer-events: none; + z-index: 1; +} + +.avatarEdit { + position: absolute; + width: 100%; + height: 100%; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + z-index: 2; + font-size: 0.3em; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + color: $very-light-gray; + background-color: rgba($color: $ung-dark-grey, $alpha: 0.6); + opacity: 0; + transition: opacity 0.2s ease-in-out; + + & > input { + display: none; + } +} diff --git a/src/components/UI/Avatar.tsx b/src/components/UI/Avatar.tsx new file mode 100644 index 0000000..055f4bd --- /dev/null +++ b/src/components/UI/Avatar.tsx @@ -0,0 +1,47 @@ +import { PropsWithoutRef } from 'react'; +import styles from './Avatar.module.scss'; +import { buildApiUrl, useAPI } from '@/api/api'; +import { useAppTranslation } from '@/lib/i18n'; +import { IconEdit } from 'obra-icons-react'; +import { ImageMedia } from './ImageMedia'; +import { c } from '@/utils'; +import { uploadPublicImage } from '@/api/media/uploadImage'; + +type AvatarProps = PropsWithoutRef<{ + localSrc?: string; + name?: string; + editable?: boolean; + className?: string; + isPublic?: boolean; + onChange?: (mediaId: string) => void; +}>; + +export default function Avatar({ localSrc, name, editable, className, onChange, isPublic }: AvatarProps) { + const api = useAPI(); + const { t } = useAppTranslation(); + + const uploadFile = async (file: File) => { + const uploadResponse = await uploadPublicImage(api, file, { public_: isPublic, preset: 'AVATAR' }); + if (uploadResponse?.id && onChange) onChange(uploadResponse.id); + }; + + return ( +
+ {editable && ( + <> + + + + )} + {name?.charAt(0) || '?'} + {localSrc && } +
+ ); +} diff --git a/src/components/UI/FileUpload.tsx b/src/components/UI/FileUpload.tsx index abbb8ef..6e6fe24 100644 --- a/src/components/UI/FileUpload.tsx +++ b/src/components/UI/FileUpload.tsx @@ -1,4 +1,4 @@ -import Rotate from '@/icons/Rotate'; +import Icons from '@/icons'; import styles from './FileUpload.module.scss'; import { MutableRefObject, useEffect, useState } from 'react'; @@ -71,7 +71,7 @@ export default function FileUpload({
{supportsPictureRotation && (
setRotation((rotation + 1) % 4)}> - +
)}
diff --git a/src/components/UI/ImageMedia.tsx b/src/components/UI/ImageMedia.tsx new file mode 100644 index 0000000..1c1c2ff --- /dev/null +++ b/src/components/UI/ImageMedia.tsx @@ -0,0 +1,54 @@ +import { buildApiUrl, useAPI } from '@/api/api'; +import { type MouseEventHandler, PropsWithoutRef, useEffect, useState } from 'react'; + +export type ImageMediaProps = PropsWithoutRef<{ + src: string; + altText?: string; + className?: string; + width?: number | 'inherit'; + height?: number | 'inherit'; + onClick?: MouseEventHandler; + displayWhileLoading?: boolean; +}>; + +export function ImageMedia({ + src: worldSrc, + altText, + className, + width, + height, + onClick, + displayWhileLoading = true, +}: ImageMediaProps) { + const [src, setSrc] = useState(''); + const api = useAPI(); + + useEffect(() => { + if (!worldSrc.startsWith(buildApiUrl('/media/image/'))) { + setSrc(worldSrc); + return; + } + api + .getFile(worldSrc.slice(buildApiUrl('').length), { forceCache: true }) + .on('success', (blob) => setSrc(URL.createObjectURL(blob!))) + .toPromise() + .then((blob) => setSrc(URL.createObjectURL(blob!))); + return () => { + if (src.startsWith('blob:')) URL.revokeObjectURL(src); + }; + }, [worldSrc]); + + return ( + (src || displayWhileLoading) && ( + {altText} + ) + ); +} diff --git a/src/components/UI/Input.tsx b/src/components/UI/Input.tsx index 455383e..b6f1a52 100644 --- a/src/components/UI/Input.tsx +++ b/src/components/UI/Input.tsx @@ -1,5 +1,5 @@ import styles from './Input.module.scss'; -import { FC, HTMLInputTypeAttribute, Ref, forwardRef } from 'react'; +import { FC, HTMLInputTypeAttribute, KeyboardEvent, Ref, forwardRef } from 'react'; import Button from '@/components/UI/Button'; function Input( @@ -16,7 +16,7 @@ function Input( }: { className?: string; onChange?: (v: T) => void; - onEnter?: () => void; + onEnter?: (event?: KeyboardEvent) => void; value?: T; placeholder?: string; type?: HTMLInputTypeAttribute; @@ -32,7 +32,7 @@ function Input( ref={ref} onChange={(v) => onChange(v.target.value as T)} onKeyDown={(e) => { - if (e.key === 'Enter') onEnter(); + if (e.key === 'Enter') onEnter(e); else if (e.key === 'ArrowUp') onArrowPressed('up'); else if (e.key === 'ArrowDown') onArrowPressed('down'); }} diff --git a/src/components/UI/LexicalPlugins/AutoLinkMatcherPlugin.tsx b/src/components/UI/LexicalPlugins/AutoLinkMatcherPlugin.tsx new file mode 100644 index 0000000..badc4b4 --- /dev/null +++ b/src/components/UI/LexicalPlugins/AutoLinkMatcherPlugin.tsx @@ -0,0 +1,19 @@ +const URL_MATCHER = + /(?:(?:https?:\/\/(?:www\.)?)|(?:www\.))[-a-zA-Z0-9@:.]{1,256}\.[a-zA-Z]{1,6}\b(?:[-a-zA-Z0-9@:%_+.~#?&//=]*)/; + +export const MATCHERS = [ + (text: string) => { + const match = URL_MATCHER.exec(text); + if (match === null) { + return null; + } + const fullMatch = match[0]; + return { + index: match.index, + length: fullMatch.length, + text: fullMatch, + url: fullMatch.startsWith('http') ? fullMatch : `https://${fullMatch}`, + attributes: { rel: 'noreferrer' }, + }; + }, +]; diff --git a/src/components/UI/LexicalPlugins/ColorTextNode.tsx b/src/components/UI/LexicalPlugins/ColorTextNode.tsx new file mode 100644 index 0000000..485bb0c --- /dev/null +++ b/src/components/UI/LexicalPlugins/ColorTextNode.tsx @@ -0,0 +1,77 @@ +import { + $getState, + $setState, + BaseStaticNodeConfig, + createState, + EditorConfig, + NodeKey, + SerializedTextNode, + Spread, + TextNode, +} from 'lexical'; +import styles from '../LexicalTextEditor.module.scss'; + +const ColorOptions = ['blue', 'darkblue', 'grey', 'darkgrey'] as const; +export type ColorType = (typeof ColorOptions)[number]; + +type SerializedColorTextNode = Spread<{ color?: ColorType }, SerializedTextNode>; + +const colorState = createState('color', { + parse: (v) => (ColorOptions.includes(v as ColorType) ? (v as ColorType) : undefined), +}); + +export class ColorTextNode extends TextNode { + $config(): BaseStaticNodeConfig { + return this.config('color-text', { + extends: TextNode, + stateConfigs: [{ flat: true, stateConfig: colorState }], + }); + } + + setColor(color?: ColorType) { + $setState(this, colorState, color); + return this; + } + + createDOM(config: EditorConfig) { + const dom = super.createDOM(config); + if ($getState(this, colorState)) dom.classList.toggle(styles[`color-text-${$getState(this, colorState)}`], true); + return dom; + } + + updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig) { + const updated = super.updateDOM(prevNode, dom, config); + if ($getState(prevNode, colorState) !== $getState(this, colorState)) + dom.classList.toggle(styles[`color-text-${$getState(prevNode, colorState)}`], false); + if ($getState(this, colorState)) dom.classList.toggle(styles[`color-text-${$getState(this, colorState)}`], true); + return updated; + } + + static importJSON(serializedNode: SerializedColorTextNode): ColorTextNode { + return $createColorTextNode(serializedNode.text).updateFromJSON(serializedNode).setColor(serializedNode.color); + } + + exportJSON(): SerializedColorTextNode { + return { + ...super.exportJSON(), + color: $getState(this, colorState), + $: undefined, + }; + } + + isSimpleText(): boolean { + return this.__type === 'color-text' && this.__mode === 0; + } +} + +export function $createColorTextNode(text?: string, nodeKey?: NodeKey): ColorTextNode { + return new ColorTextNode(text, nodeKey); +} + +export function $createColorTextNodeFromTextNode(textNode: TextNode, color?: ColorType): ColorTextNode { + return $createColorTextNode(textNode.getTextContent()).updateFromJSON(textNode.exportJSON()).setColor(color); +} + +export function $isColorTextNode(node: unknown): node is ColorTextNode { + return node instanceof ColorTextNode; +} diff --git a/src/components/UI/LexicalPlugins/ColorTextPlugin.tsx b/src/components/UI/LexicalPlugins/ColorTextPlugin.tsx new file mode 100644 index 0000000..dae0ed8 --- /dev/null +++ b/src/components/UI/LexicalPlugins/ColorTextPlugin.tsx @@ -0,0 +1,57 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { + $addUpdateTag, + $getSelection, + $isRangeSelection, + $isTextNode, + COMMAND_PRIORITY_EDITOR, + createCommand, + SKIP_SELECTION_FOCUS_TAG, +} from 'lexical'; +import { useEffect } from 'react'; +import { $createColorTextNodeFromTextNode, $isColorTextNode, ColorTextNode, ColorType } from './ColorTextNode'; + +export const FORMAT_COLOR_COMMAND = createCommand('FORMAT_COLOR_COMMAND'); + +export function ColorTextPlugin() { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + if (!editor.hasNodes([ColorTextNode])) throw new Error('ColorTextPlugin: ColorTextNode not registered on editor'); + + return editor.registerCommand( + FORMAT_COLOR_COMMAND, + (color) => { + editor.update(() => { + $addUpdateTag(SKIP_SELECTION_FOCUS_TAG); + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + const colorNodes = selection + .getNodes() + .map((node) => ($isTextNode(node) ? $createColorTextNodeFromTextNode(node, color) : node)); + const lastIndex = colorNodes.length - 1; + const lastColorNode = colorNodes[lastIndex]; + // Order is important here, we must start by the last one in case it is also the first one. + if ($isColorTextNode(lastColorNode)) + colorNodes[lastIndex] = lastColorNode.spliceText( + selection.isBackward() ? selection.anchor.offset : selection.focus.offset, + lastColorNode.getTextContent().length, + '', + ); + if ($isColorTextNode(colorNodes[0])) + colorNodes[0] = colorNodes[0].spliceText( + 0, + selection.isBackward() ? selection.focus.offset : selection.anchor.offset, + '', + ); + selection.insertNodes(colorNodes); + } + }); + return true; + }, + COMMAND_PRIORITY_EDITOR, + ); + }, [editor]); + + return null; +} diff --git a/src/components/UI/LexicalPlugins/EnableDisablePlugin.tsx b/src/components/UI/LexicalPlugins/EnableDisablePlugin.tsx new file mode 100644 index 0000000..1072a8a --- /dev/null +++ b/src/components/UI/LexicalPlugins/EnableDisablePlugin.tsx @@ -0,0 +1,26 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { MutableRefObject, PropsWithRef, useEffect } from 'react'; + +/** + * @param disabled + * @param ref Will contain a function that will allow you to update the editor state. + */ +export function EnableDisablePlugin({ + disabled, + ref, +}: PropsWithRef<{ disabled: boolean; ref?: MutableRefObject<(s: string) => void> }>) { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + editor?.setEditable(!disabled); + + if (ref) { + ref.current = (s) => { + const setEditorState = () => editor.setEditorState(editor.parseEditorState(s)); + editor.update(setEditorState); + }; + } + }, [editor, disabled]); + + return <>; +} diff --git a/src/components/UI/LexicalPlugins/ImageDropPlugin.tsx b/src/components/UI/LexicalPlugins/ImageDropPlugin.tsx new file mode 100644 index 0000000..186edda --- /dev/null +++ b/src/components/UI/LexicalPlugins/ImageDropPlugin.tsx @@ -0,0 +1,102 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { mergeRegister } from '@lexical/utils'; +import { + $addUpdateTag, + COMMAND_PRIORITY_HIGH, + COMMAND_PRIORITY_LOW, + createCommand, + DRAGOVER_COMMAND, + DROP_COMMAND, + LexicalEditor, + PASTE_COMMAND, + SKIP_SELECTION_FOCUS_TAG, +} from 'lexical'; +import { useEffect, useState } from 'react'; +import { ImageNode } from './ImageNode'; +import { INSERT_IMAGE_COMMAND } from './ImagePlugin'; +import { API, buildApiUrl, useAPI } from '@/api/api'; +import { useAppTranslation } from '@/lib/i18n'; +import styles from '../LexicalTextEditor.module.scss'; +import { uploadPublicImage } from '@/api/media/uploadImage'; + +export const DRAGLEAVE_COMMAND = createCommand('DRAGLEAVE_COMMAND'); + +export async function uploadFile(file: File, api: API, editor: LexicalEditor) { + const uploadResponse = await uploadPublicImage(api, file); + editor.update(() => { + $addUpdateTag(SKIP_SELECTION_FOCUS_TAG); + editor.dispatchCommand(INSERT_IMAGE_COMMAND, { + src: buildApiUrl(`/media/image/${uploadResponse!.id}.webp`), + width: uploadResponse!.width, + height: uploadResponse!.height, + }); + }); +} + +export function ImageDropPlugin() { + const [editor] = useLexicalComposerContext(); + const [isHovered, setIsHovered] = useState(false); + const api = useAPI(); + const { t } = useAppTranslation(); + + function onDragOver(event: DragEvent) { + if (event.dataTransfer!.types.includes('Files')) { + setIsHovered(true); + event.preventDefault(); + return true; + } + return false; + } + + function onDragLeave() { + setIsHovered(false); + return true; + } + + function onDrop(event: DragEvent, editor: LexicalEditor) { + const file = getImagesFromFileList(event.dataTransfer!.files); + if (file.length) { + setIsHovered(false); + event.preventDefault(); + } + file.forEach((f) => uploadFile(f, api, editor)); + return !!file.length; + } + + useEffect(() => { + if (!editor.hasNodes([ImageNode])) throw new Error('ImagePlugin: ImageNode not registered on editor'); + + const listener = (event: DragEvent) => editor.dispatchCommand(DRAGLEAVE_COMMAND, event); + editor.getRootElement()?.addEventListener('dragleave', listener); + return mergeRegister( + editor.registerCommand(DRAGOVER_COMMAND, (event) => onDragOver(event), COMMAND_PRIORITY_LOW), + editor.registerCommand(DRAGLEAVE_COMMAND, () => onDragLeave(), COMMAND_PRIORITY_HIGH), + editor.registerCommand(DROP_COMMAND, (event) => onDrop(event, editor), COMMAND_PRIORITY_HIGH), + editor.registerCommand( + PASTE_COMMAND, + (event) => { + if (event instanceof ClipboardEvent) { + const files = getImagesFromFileList(event.clipboardData!.files); + if (files.length) event.preventDefault(); + files.forEach((file) => uploadFile(file, api, editor)); + return !!files.length; + } + return false; + }, + COMMAND_PRIORITY_HIGH, + ), + () => editor.getRootElement()?.removeEventListener('dragleave', listener), + ); + }, [editor]); + + return
{isHovered && t('common:rte.dnd.drop')}
; +} + +function getImagesFromFileList(fileList: FileList) { + const list = [] as File[]; + for (let i = 0; i < fileList.length; i++) { + const file = fileList[i]; + if (file.type.startsWith('image/')) list.push(file); + } + return list; +} diff --git a/src/components/UI/LexicalPlugins/ImageNode.tsx b/src/components/UI/LexicalPlugins/ImageNode.tsx new file mode 100644 index 0000000..3059934 --- /dev/null +++ b/src/components/UI/LexicalPlugins/ImageNode.tsx @@ -0,0 +1,147 @@ +import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection'; +import { + $getState, + $setState, + BaseStaticNodeConfig, + createState, + DecoratorNode, + EditorConfig, + NodeKey, + SerializedLexicalNode, + Spread, +} from 'lexical'; +import { ImageMedia } from '../ImageMedia'; +import styles from '../LexicalTextEditor.module.scss'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; + +type SerializedImageNode = Spread< + { + src: string; + altText: string; + width: number | 'inherit'; + height: number | 'inherit'; + }, + SerializedLexicalNode +>; + +export const srcState = createState('src', { + parse: (value) => (typeof value === 'string' ? value : ''), +}); + +export const altTextState = createState('altText', { + parse: (value) => (typeof value === 'string' ? value : ''), +}); + +export const widthState = createState('width', { + parse: (value) => (value === 'inherit' || typeof value === 'number' ? value : 'inherit'), +}); + +export const heightState = createState('height', { + parse: (value) => (value === 'inherit' || typeof value === 'number' ? value : 'inherit'), +}); + +export class ImageNode extends DecoratorNode { + $config(): BaseStaticNodeConfig { + return this.config('image', { + extends: DecoratorNode, + stateConfigs: [ + { flat: true, stateConfig: srcState }, + { flat: true, stateConfig: altTextState }, + { flat: true, stateConfig: widthState }, + { flat: true, stateConfig: heightState }, + ], + }); + } + + setSrc(src: string) { + $setState(this, srcState, src); + return this; + } + + setAltText(altText: string) { + $setState(this, altTextState, altText); + return this; + } + + setWidth(width: number | 'inherit') { + $setState(this, widthState, width); + return this; + } + + setHeight(height: number | 'inherit') { + $setState(this, heightState, height); + return this; + } + + createDOM(config: EditorConfig) { + const span = document.createElement('span'); + if (config?.theme?.image) span.className = config.theme.image; + return span; + } + + decorate() { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const image = this; + function ImageComponent() { + const [editor] = useLexicalComposerContext(); + const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(image.getKey()); + return ( + { + if (!editor._editable) { + setSelected(false); + clearSelection(); + } else if (event.shiftKey) { + setSelected(!isSelected); + } else { + clearSelection(); + setSelected(true); + } + event.preventDefault(); + event.stopPropagation(); + return true; + }} + /> + ); + } + return ; + } + + static importJSON(serializedNode: SerializedImageNode): ImageNode { + return $createImageNode( + serializedNode.src, + serializedNode.altText, + serializedNode.width, + serializedNode.height, + ).updateFromJSON(serializedNode); + } + + exportJSON(): SerializedImageNode { + return { + ...super.exportJSON(), + src: $getState(this, srcState), + altText: $getState(this, altTextState), + width: $getState(this, widthState), + height: $getState(this, heightState), + }; + } +} + +export function $createImageNode( + src: string, + altText: string = '', + width: number | 'inherit' = 'inherit', + height: number | 'inherit' = 'inherit', + nodeKey?: NodeKey, +): ImageNode { + return new ImageNode(nodeKey).setSrc(src).setAltText(altText).setWidth(width).setHeight(height); +} + +export function $isImageNode(node: unknown): node is ImageNode { + return node instanceof ImageNode; +} diff --git a/src/components/UI/LexicalPlugins/ImagePlugin.tsx b/src/components/UI/LexicalPlugins/ImagePlugin.tsx new file mode 100644 index 0000000..48160ec --- /dev/null +++ b/src/components/UI/LexicalPlugins/ImagePlugin.tsx @@ -0,0 +1,186 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { $wrapNodeInElement, mergeRegister } from '@lexical/utils'; +import { + $createParagraphNode, + $createRangeSelection, + $getSelection, + $getState, + $insertNodes, + $isNodeSelection, + $isRootOrShadowRoot, + $setSelection, + COMMAND_PRIORITY_EDITOR, + COMMAND_PRIORITY_HIGH, + COMMAND_PRIORITY_LOW, + createCommand, + DRAGOVER_COMMAND, + DRAGSTART_COMMAND, + DROP_COMMAND, + LexicalEditor, + TextNode, +} from 'lexical'; +import { useEffect } from 'react'; +import { + $createImageNode, + $isImageNode, + altTextState, + heightState, + ImageNode, + srcState, + widthState, +} from './ImageNode'; + +export const INSERT_IMAGE_COMMAND = createCommand<{ + src: string; + key?: string; + altText?: string; + width?: number; + height?: number; +}>('INSERT_IMAGE_COMMAND'); + +function textNodeTransform(node: TextNode): void { + if (!node.isSimpleText() || node.hasFormat('code')) return; + + const text = node.getTextContent(); + const match = text.match(/(?:https:\/\/|www\.)\S+?\.(?:jpe?g|png|webp)(?:\?\S+)?/); + if (!match || typeof match.index !== 'number') return; + const start = match.index; + const end = start + match[0].length; + + let targetNode; + if (start === 0) [targetNode] = node.splitText(end); + else [, targetNode] = node.splitText(start, end); + const imageNode = $createImageNode(match[0]); + targetNode.replace(imageNode); +} + +export function ImagePlugin() { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + if (!editor.hasNodes([ImageNode])) throw new Error('ImagePlugin: ImageNode not registered on editor'); + + return mergeRegister( + editor.registerCommand( + INSERT_IMAGE_COMMAND, + (payload) => { + const imageNode = $createImageNode(payload.src, payload.altText, payload.width, payload.height, payload.key); + $insertNodes([imageNode]); + if ($isRootOrShadowRoot(imageNode.getParentOrThrow())) + $wrapNodeInElement(imageNode, $createParagraphNode).selectEnd(); + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand(DRAGSTART_COMMAND, (event) => onDragStart(event), COMMAND_PRIORITY_HIGH), + editor.registerCommand(DRAGOVER_COMMAND, (event) => onDragover(event, editor), COMMAND_PRIORITY_LOW), + editor.registerCommand(DROP_COMMAND, (event) => onDrop(event, editor), COMMAND_PRIORITY_HIGH), + editor.registerNodeTransform(TextNode, textNodeTransform), + ); + }, [editor]); + + return null; +} + +const TRANSPARENT_IMAGE = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; + +function onDragStart(event: DragEvent) { + const node = getImageNodeInSelection(); + if (!node) return false; + const dataTransfer = event.dataTransfer; + if (!dataTransfer) return false; + const img = document.createElement('img'); + img.src = TRANSPARENT_IMAGE; + dataTransfer.setData('text/plain', '_'); + dataTransfer.setDragImage(img, 0, 0); + dataTransfer.setData( + 'application/x-lexical-drag', + JSON.stringify({ + data: { + altText: $getState(node, altTextState), + height: $getState(node, heightState), + width: $getState(node, widthState), + src: $getState(node, srcState), + key: node.getKey(), + }, + type: ImageNode.getType(), + }), + ); + return true; +} + +function onDragover(event: DragEvent, editor: LexicalEditor) { + const node = getImageNodeInSelection(); + if (!node) return false; + if (!canDropImage(event, editor)) event.preventDefault(); + return true; +} + +function onDrop(event: DragEvent, editor: LexicalEditor) { + const node = getImageNodeInSelection(); + if (!node) return false; + const data = getDragImageData(event); + if (!data) return false; + event.preventDefault(); + if (canDropImage(event, editor)) { + const range = getDragSelection(event); + node.remove(); + const rangeSelection = $createRangeSelection(); + if (range !== null && range !== undefined) rangeSelection.applyDOMRange(range); + $setSelection(rangeSelection); + editor.dispatchCommand(INSERT_IMAGE_COMMAND, data); + } + return true; +} + +function getImageNodeInSelection() { + const selection = $getSelection(); + if (!$isNodeSelection(selection)) return null; + const nodes = selection.getNodes(); + const node = nodes[0]; + return $isImageNode(node) ? node : null; +} + +function getDragImageData(event: DragEvent) { + const dragData = event.dataTransfer?.getData('application/x-lexical-drag'); + if (!dragData) return null; + const { type, data } = JSON.parse(dragData); + if (type !== ImageNode.getType()) return null; + return data; +} + +function canDropImage(event: DragEvent, editor: LexicalEditor): boolean { + const target = event.target; + return !!( + target && + target instanceof HTMLElement && + !target.closest(`code, span.${editor._config.theme.image}`) && + target.parentElement && + target.parentElement.closest(`div.${editor._config.theme.root}`) + ); +} + +declare global { + interface DragEvent { + rangeOffset?: number; + rangeParent?: Node; + } +} + +function getDragSelection(event: DragEvent): Range | null | undefined { + let range; + const target = event.target as HTMLElement; + const targetWindow = + target?.nodeType === 9 ? (target as unknown as Document).defaultView : target?.ownerDocument?.defaultView; + const domSelection = (targetWindow || window).getSelection(); + if (document.caretRangeFromPoint) { + range = document.caretRangeFromPoint(event.clientX, event.clientY); + } else if (event.rangeParent && domSelection !== null) { + domSelection.collapse(event.rangeParent, event.rangeOffset || 0); + range = domSelection.getRangeAt(0); + } else { + throw Error(`Cannot get the selection when dragging`); + } + + return range; +} diff --git a/src/components/UI/LexicalPlugins/ToolbarPlugin.tsx b/src/components/UI/LexicalPlugins/ToolbarPlugin.tsx new file mode 100644 index 0000000..01243c7 --- /dev/null +++ b/src/components/UI/LexicalPlugins/ToolbarPlugin.tsx @@ -0,0 +1,513 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { $getNearestNodeOfType, mergeRegister } from '@lexical/utils'; +import { + $addUpdateTag, + $createParagraphNode, + $findMatchingParent, + $getSelection, + $isElementNode, + $isRangeSelection, + CAN_REDO_COMMAND, + CAN_UNDO_COMMAND, + COMMAND_PRIORITY_LOW, + ElementNode, + FORMAT_ELEMENT_COMMAND, + FORMAT_TEXT_COMMAND, + LexicalEditor, + LexicalNode, + RangeSelection, + REDO_COMMAND, + SELECTION_CHANGE_COMMAND, + SKIP_SELECTION_FOCUS_TAG, + TextNode, + UNDO_COMMAND, +} from 'lexical'; +import { + IconAlignText4Center, + IconAlignText4Justify, + IconAlignText4Left, + IconAlignText4Right, + IconBold, + IconChecklist, + IconCode, + IconImage, + IconItalic, + IconLink, + IconNext, + IconOrderedList, + IconPalette, + IconPrevious, + IconQuoteFill, + IconStrikethrough, + IconTable, + IconText, + IconUnderline, + IconUnorderedList, +} from 'obra-icons-react'; +import { PropsWithChildren, useCallback, useEffect, useRef, useState } from 'react'; +import styles from '../LexicalTextEditor.module.scss'; +import { + $createHeadingNode, + $createQuoteNode, + $isHeadingNode, + HeadingNode, + HeadingTagType, + QuoteNode, +} from '@lexical/rich-text'; +import { + $isListNode, + INSERT_CHECK_LIST_COMMAND, + INSERT_ORDERED_LIST_COMMAND, + INSERT_UNORDERED_LIST_COMMAND, + ListNode, +} from '@lexical/list'; +import { $isAtNodeEnd, $setBlocksType } from '@lexical/selection'; +import { $isLinkNode, LinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'; +import { $isTableNode, $isTableSelection, INSERT_TABLE_COMMAND, TableNode } from '@lexical/table'; +import Input from '../Input'; +import { useAppTranslation } from '@/lib/i18n'; +import { useAPI } from '@/api/api'; +import { uploadFile } from './ImageDropPlugin'; +import { FORMAT_COLOR_COMMAND } from './ColorTextPlugin'; +import type { InitialConfigType } from '@lexical/react/LexicalComposer'; +import { ColorTextNode } from './ColorTextNode'; +import { CodeNode } from '@lexical/code'; +import { ImageNode } from './ImageNode'; +import { $findTopLevelElement } from '@/components/UI/LexicalPlugins/utils'; + +function Divider() { + return
; +} + +type ToolbarFloatingMenuProps = PropsWithChildren<{ display: boolean }>; +export function ToolbarFloatingMenu({ children, display }: ToolbarFloatingMenuProps) { + return ( + <> + {display && ( +
e.stopPropagation()}> + {children} +
+ )} + + ); +} + +export function ToolbarPlugin({ enabledNodes }: { enabledNodes: InitialConfigType['nodes'] }) { + const [editor] = useLexicalComposerContext(); + const toolbarRef = useRef(null); + const [canUndo, setCanUndo] = useState(false); + const [canRedo, setCanRedo] = useState(false); + const [isBold, setIsBold] = useState(false); + const [isItalic, setIsItalic] = useState(false); + const [isUnderline, setIsUnderline] = useState(false); + const [isStrikethrough, setIsStrikethrough] = useState(false); + const [isCode, setIsCode] = useState(false); + const [link, setLink] = useState(null); + const [editingLink, setEditingLink] = useState(''); + const [align, setAlign] = useState('left'); + const [blockType, setBlockType] = useState(null); + const [isColorPaletteOpen, setIsColorPaletteOpen] = useState(false); + const [isTablePaletteOpen, setIsTablePaletteOpen] = useState(false); + const [tablePaletteHoverIndex, setTablePaletteHoverIndex] = useState(-1); + const [isLinkPaletteOpen, setIsLinkPaletteOpen] = useState(false); + const [isFilePaletteOpen, setIsFilePaletteOpen] = useState(false); + const { t } = useAppTranslation(); + const api = useAPI(); + + function getSelectedNode(selection: RangeSelection): TextNode | ElementNode { + const anchorNode = selection.anchor.getNode(); + const focusNode = selection.focus.getNode(); + if (anchorNode === focusNode) return anchorNode; + return selection.isBackward() + ? $isAtNodeEnd(selection.focus) + ? anchorNode + : focusNode + : $isAtNodeEnd(selection.anchor) + ? anchorNode + : focusNode; + } + + const $updateToolbar = useCallback(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection) || $isTableSelection(selection)) { + setIsBold(selection.hasFormat('bold')); + setIsItalic(selection.hasFormat('italic')); + setIsUnderline(selection.hasFormat('underline')); + setIsStrikethrough(selection.hasFormat('strikethrough')); + setIsCode(selection.hasFormat('code')); + } + if ($isRangeSelection(selection)) { + const anchorNode = selection!.anchor.getNode(); + const element = $findTopLevelElement(anchorNode); + const elementKey = element.getKey(); + const elementDOM = editor.getElementByKey(elementKey); + + let type: string | null = null; + if (elementDOM !== null) { + if ($isListNode(element)) { + const parentList = $getNearestNodeOfType(anchorNode, ListNode); + type = parentList ? parentList.getListType() : element.getListType(); + } else type = $isHeadingNode(element) ? element.getTag() : (element.getType() as 'paragraph' | 'quote'); + } + + const node = getSelectedNode(selection); + const parent = node.getParent(); + let alignCheckNode: LexicalNode = node; + let link: string | null = null; + if ($isLinkNode(parent)) link = parent.getURL(); + if ($isLinkNode(node)) { + link = node.getURL(); + alignCheckNode = $findMatchingParent( + node, + (parentNode) => $isElementNode(parentNode) && !parentNode.isInline(), + )!; + } + setLink(link); + if ($findMatchingParent(node, $isTableNode)) type = 'table'; + + setBlockType(type); + setAlign( + $isElementNode(alignCheckNode) + ? alignCheckNode.getFormatType() + : $isElementNode(node) + ? node.getFormatType() + : parent?.getFormatType() || 'left', + ); + } + }, []); + + const toggleToolbarFloatingMenu = (type: 'color' | 'table' | 'link' | 'file') => { + setIsColorPaletteOpen(type === 'color' ? !isColorPaletteOpen : false); + setIsTablePaletteOpen(type === 'table' ? !isTablePaletteOpen : false); + setIsLinkPaletteOpen(type === 'link' ? !isLinkPaletteOpen : false); + setIsFilePaletteOpen(type === 'file' ? !isFilePaletteOpen : false); + if (type === 'link' && !isLinkPaletteOpen) setEditingLink(link || ''); + }; + + useEffect(() => { + return mergeRegister( + editor.registerUpdateListener(({ editorState }) => { + editorState.read(() => $updateToolbar(), { editor }); + }), + editor.registerCommand(SELECTION_CHANGE_COMMAND, () => ($updateToolbar(), false), COMMAND_PRIORITY_LOW), + editor.registerCommand(CAN_UNDO_COMMAND, (payload) => (setCanUndo(payload), false), COMMAND_PRIORITY_LOW), + editor.registerCommand(CAN_REDO_COMMAND, (payload) => (setCanRedo(payload), false), COMMAND_PRIORITY_LOW), + ); + }, [editor, $updateToolbar]); + + return ( +
+ + + + + + + + {enabledNodes?.includes(ColorTextNode) && ( + + )} + + {enabledNodes?.includes(HeadingNode) && ( + <> + + + + + + )} + {enabledNodes?.includes(QuoteNode) && ( + + )} + {enabledNodes?.includes(CodeNode) && ( + + )} + {enabledNodes?.includes(ListNode) && ( + <> + + + + + )} + {enabledNodes?.includes(TableNode) && ( + + )} + + {enabledNodes?.includes(LinkNode) && ( + + )} + {enabledNodes?.includes(ImageNode) && ( + + )} + + + + + +
+ ); +} + +export const formatParagraph = (editor: LexicalEditor) => { + editor.update(() => { + $addUpdateTag(SKIP_SELECTION_FOCUS_TAG); + const selection = $getSelection(); + $setBlocksType(selection, () => $createParagraphNode()); + }); +}; + +export const formatHeading = (editor: LexicalEditor, blockType: string | null, headingSize: HeadingTagType) => { + if (blockType !== headingSize) { + editor.update(() => { + $addUpdateTag(SKIP_SELECTION_FOCUS_TAG); + const selection = $getSelection(); + $setBlocksType(selection, () => $createHeadingNode(headingSize)); + }); + } +}; + +export const formatBulletList = (editor: LexicalEditor, blockType: string | null) => { + if (blockType !== 'bullet') { + editor.update(() => { + $addUpdateTag(SKIP_SELECTION_FOCUS_TAG); + editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined); + }); + } else { + formatParagraph(editor); + } +}; + +export const formatCheckList = (editor: LexicalEditor, blockType: string | null) => { + if (blockType !== 'check') { + editor.update(() => { + $addUpdateTag(SKIP_SELECTION_FOCUS_TAG); + editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined); + }); + } else { + formatParagraph(editor); + } +}; + +export const formatNumberedList = (editor: LexicalEditor, blockType: string | null) => { + if (blockType !== 'number') { + editor.update(() => { + $addUpdateTag(SKIP_SELECTION_FOCUS_TAG); + editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined); + }); + } else { + formatParagraph(editor); + } +}; + +export const formatQuote = (editor: LexicalEditor, blockType: string | null) => { + if (blockType !== 'quote') { + editor.update(() => { + $addUpdateTag(SKIP_SELECTION_FOCUS_TAG); + const selection = $getSelection(); + $setBlocksType(selection, () => $createQuoteNode()); + }); + } +}; + +export const formatTable = (editor: LexicalEditor, cellIndex: number) => { + editor.update(() => { + $addUpdateTag(SKIP_SELECTION_FOCUS_TAG); + editor.dispatchCommand(INSERT_TABLE_COMMAND, { + columns: `${1 + (cellIndex % 8)}`, + rows: `${1 + Math.floor(cellIndex / 8)}`, + }); + }); +}; + +export const formatLink = (editor: LexicalEditor, url: string) => { + editor.update(() => editor.dispatchCommand(TOGGLE_LINK_COMMAND, url || null)); +}; diff --git a/src/components/UI/LexicalPlugins/utils.ts b/src/components/UI/LexicalPlugins/utils.ts new file mode 100644 index 0000000..8f24a79 --- /dev/null +++ b/src/components/UI/LexicalPlugins/utils.ts @@ -0,0 +1,13 @@ +import { $findMatchingParent, $isRootOrShadowRoot, LexicalNode } from 'lexical'; + +export function $findTopLevelElement(node: LexicalNode) { + let topLevelElement = + node.getKey() === 'root' + ? node + : $findMatchingParent(node, (e) => { + const parent = e.getParent(); + return parent !== null && $isRootOrShadowRoot(parent); + }); + if (topLevelElement === null) topLevelElement = node.getTopLevelElementOrThrow(); + return topLevelElement; +} diff --git a/src/components/UI/LexicalTextEditor.module.scss b/src/components/UI/LexicalTextEditor.module.scss new file mode 100644 index 0000000..163025d --- /dev/null +++ b/src/components/UI/LexicalTextEditor.module.scss @@ -0,0 +1,317 @@ +@import '@/variables'; + +.editor-root { + position: relative; + margin: 5px -5px -5px; + padding: 5px; + outline: none; + + &[contenteditable='true'] { + border: 1px solid rgba($color: $ung-light-grey, $alpha: 0.6); + border-radius: 5px; + min-height: 100px; + } +} + +.editor-image { + img { + max-width: 100%; + max-height: 100%; + height: auto; + border-radius: 3px; + background-color: rgba($color: $ung-light-grey, $alpha: 0.5); + &.selected { + outline: 3px solid $ung-light-blue; + border-radius: 0; + } + } +} + +.toolbar { + display: flex; + flex-flow: row wrap; + gap: 5px; + margin: 5px 0; + align-items: center; + + .divider { + width: 1px; + height: 2em; + margin: 0 5px; + background-color: rgba($color: $ung-light-grey, $alpha: 0.5); + + & + .divider { + display: none; + } + } + + .item { + position: relative; + border: none; + border-radius: 3px; + padding: 0; + height: calc(28px + 0.2em); + width: calc(28px + 0.2em); + text-align: center; + padding: 0.1em; + background-color: $very-light-gray; + color: $ung-light-blue; + border: 2px solid $ung-light-blue; + cursor: pointer; + + &.active { + background-color: $ung-light-blue; + color: $very-light-gray; + } + + &:disabled { + background-color: desaturate($color: $ung-light-grey, $amount: 100%); + border-color: desaturate($color: $ung-light-grey, $amount: 100%); + color: $very-light-gray; + cursor: not-allowed; + } + + .floatingMenu { + position: absolute; + top: 100%; + left: 50%; + transform: translate(-50%, 6px); + background-color: $very-light-gray; + border: 2px solid $ung-light-blue; + color: $ung-light-blue; + border-radius: 5px; + padding: 5px; + display: flex; + flex-flow: row wrap; + gap: 2px; + z-index: 10; + max-width: 188px; + width: max-content; + + &::before { + content: ''; + position: absolute; + top: -6px; + left: 50%; + transform: translateX(-50%); + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-bottom: 6px solid $ung-light-blue; + } + } + } +} + +.editor-link { + color: $ung-light-blue; + border-bottom: 1px solid $ung-light-blue; +} + +.placeholderContainer { + position: relative; + + .placeholder { + color: rgba($color: $ung-dark-grey, $alpha: 0.6); + font-style: italic; + position: absolute; + top: 6px; + pointer-events: none; + } +} + +.dropZone { + position: absolute; + top: 0; + left: -5px; + right: -5px; + bottom: 0; + background-color: rgba($color: $ung-light-blue, $alpha: 0.3); + border: 4px dashed $ung-light-blue; + display: flex; + align-items: center; + justify-content: center; + text-transform: uppercase; + color: $ung-dark-grey; + font-size: 2em; + border-radius: 5px; + pointer-events: none; +} + +.color-palette, +.table-palette { + width: 20px; + height: 20px; + border: 2px solid rgba($color: $ung-light-grey, $alpha: 0.5); + border-radius: 1px; + + &.color-palette-blue { + background-color: $ung-light-blue; + } + &.color-palette-darkblue { + background-color: darken($color: $ung-light-blue, $amount: 20%); + } + &.color-palette-grey { + background-color: $ung-light-grey; + } + &.color-palette-darkgrey { + background-color: $ung-dark-grey; + } + &.active { + background-color: rgba($color: $ung-light-blue, $alpha: 0.2); + } +} + +.link-palette { + border: none; + background-color: transparent; + &:focus-within { + background-color: transparent; + } + input { + background-color: transparent; + padding: 2px; + } +} + +.file-palette { + display: block; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + + & > input { + display: none; + } +} + +.color-text-blue { + color: $ung-light-blue; +} +.color-text-darkblue { + color: darken($color: $ung-light-blue, $amount: 20%); +} +.color-text-grey { + color: $ung-light-grey; +} +.color-text-darkgrey { + color: $ung-dark-grey; +} + +.bold { + font-weight: bold; +} +.italic { + font-style: italic; +} +.underline { + text-decoration: underline; +} +.strikethrough { + text-decoration: line-through; +} + +.editor-quote { + background-color: rgba($color: $ung-dark-grey, $alpha: 0.1); + margin: 0.5em; + padding: 0.5em; + border-left: 5px solid $ung-dark-grey; + border-radius: 0 5px 5px 0; +} + +.editor-horizontal-rule { + border: none; + border-top: 2px solid $ung-dark-grey; + margin: 1em 0; +} + +.editor-list-item { + position: relative; +} + +.editor-list-item:has(.editor-ordered-list), +.editor-list-item:has(.editor-unordered-list), +.editor-list-item-checked, +.editor-list-item-unchecked { + list-style-type: none; +} + +.editor-checklist { + margin-left: -1.5em; +} + +.editor-list-item-unchecked, +.editor-list-item-checked { + position: relative; + margin-left: 0.5em; + margin-right: 0.5em; + padding-left: 1.5em; + padding-right: 1.5em; + outline: none; + display: block; + + &::before { + content: ''; + width: 0.9em; + height: 0.9em; + top: 50%; + left: 0; + cursor: pointer; + display: block; + background-size: cover; + position: absolute; + transform: translateY(-50%); + } +} +.editor-list-item-checked { + &::before { + border: 1px solid $ung-light-blue; + border-radius: 2px; + background-color: $ung-light-blue; + background-repeat: no-repeat; + } + &::after { + content: ''; + cursor: pointer; + border-color: $very-light-gray; + border-style: solid; + position: absolute; + display: block; + top: 45%; + width: 0.2em; + left: 0.32em; + height: 0.4em; + transform: translateY(-50%) rotate(45deg); + border-width: 0 0.1em 0.1em 0; + } +} + +.editor-list-item-unchecked::before { + border: 1px solid rgba($color: $ung-light-grey, $alpha: 0.5); + border-radius: 2px; +} + +.editor-table { + border-collapse: collapse; + + .editor-table-row:nth-child(2n) { + background-color: rgba($color: $ung-light-grey, $alpha: 0.1); + } + .editor-table-cell { + padding: 3px 4px; + min-width: 200px; + border: 1px solid rgba($color: $ung-light-grey, $alpha: 0.3); + } + .editor-table-cell-header { + background-color: $ung-dark-grey; + color: $very-light-gray; + border: 1px solid $ung-dark-grey; + } +} + +.editor-code { + background-color: rgba($color: $ung-dark-grey, $alpha: 0.1); + border-radius: 3px; +} diff --git a/src/components/UI/LexicalTextEditor.tsx b/src/components/UI/LexicalTextEditor.tsx new file mode 100644 index 0000000..5c96421 --- /dev/null +++ b/src/components/UI/LexicalTextEditor.tsx @@ -0,0 +1,240 @@ +import { InitialConfigType, LexicalComposer } from '@lexical/react/LexicalComposer'; +import { ContentEditable } from '@lexical/react/LexicalContentEditable'; +import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'; +import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; +import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; +import { ListPlugin } from '@lexical/react/LexicalListPlugin'; +import { CheckListPlugin } from '@lexical/react/LexicalCheckListPlugin'; +import { TablePlugin } from '@lexical/react/LexicalTablePlugin'; +import { TabIndentationPlugin } from '@lexical/react/LexicalTabIndentationPlugin'; +import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'; +import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin'; +import { MarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPlugin'; +import { HorizontalRulePlugin } from '@lexical/react/LexicalHorizontalRulePlugin'; +import { AutoLinkPlugin } from '@lexical/react/LexicalAutoLinkPlugin'; +import { AutoLinkNode, LinkNode } from '@lexical/link'; +import { HeadingNode, QuoteNode } from '@lexical/rich-text'; +import { CodeHighlightNode, CodeNode } from '@lexical/code'; +import { TableNode, TableCellNode, TableRowNode } from '@lexical/table'; +import { ListNode, ListItemNode } from '@lexical/list'; +import { HorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode'; +import { ToolbarPlugin } from './LexicalPlugins/ToolbarPlugin'; +import { EnableDisablePlugin } from './LexicalPlugins/EnableDisablePlugin'; +import { MATCHERS } from './LexicalPlugins/AutoLinkMatcherPlugin'; +import { ImageNode } from './LexicalPlugins/ImageNode'; +import { $createColorTextNodeFromTextNode, ColorTextNode } from './LexicalPlugins/ColorTextNode'; +import { ImageDropPlugin } from './LexicalPlugins/ImageDropPlugin'; +import { ColorTextPlugin } from './LexicalPlugins/ColorTextPlugin'; +import { ImagePlugin } from './LexicalPlugins/ImagePlugin'; +import styles from './LexicalTextEditor.module.scss'; +import { TextNode, type EditorThemeClasses } from 'lexical'; +import type { FC, MutableRefObject } from 'react'; + +/** + * Bundle of features (nodes and plugins) to use in Lexical Editor. + * + * Contains: + * - `nodes`: List of custom nodes to register in Lexical Editor {@link InitialConfigType} + * - `plugins`: List of Lexical plugins to use in the Editor. Each plugin can be a React + * Functional Component (the type you'll get when importing plugins) or an object containing + * the plugin as `plugin` property and an `options` property containing props to pass to the + * plugin. When adding a plugin, make sure its required nodes are also added in the `nodes` list. + * + * **Important Note:** When using {@link ColorTextNode}, make sure to add a node replacement rule to turn + * any{@link TextNode} into a {@link ColorTextNode} such as described {@link https://lexical.dev/docs/concepts/node-replacement in the docs}. + */ +export type RTEFeatureBundle = { + nodes: InitialConfigType['nodes']; + plugins: (FC | { plugin: FC; options: Record })[]; +}; + +/** Preconfigurations for Editor, see {@link $registerBundle} to create other bundles and {@link RTEFeatureBundle} for types. */ +const EDITOR_BUNDLES = { + '@etuutt/simple': { + nodes: [], + plugins: [], + }, + '@etuutt/full': { + nodes: [ + AutoLinkNode, + CodeHighlightNode, + CodeNode, + ColorTextNode, + HeadingNode, + HorizontalRuleNode, + ImageNode, + LinkNode, + ListItemNode, + ListNode, + TableCellNode, + TableNode, + TableRowNode, + QuoteNode, + { + replace: TextNode, + with: (node: TextNode) => { + return $createColorTextNodeFromTextNode(node); + }, + withKlass: ColorTextNode, + }, + ], + plugins: [ + ImageDropPlugin, + ImagePlugin, + ColorTextPlugin, + LinkPlugin, + ListPlugin, + CheckListPlugin, + TablePlugin, + TabIndentationPlugin, + HorizontalRulePlugin, + MarkdownShortcutPlugin, + { plugin: AutoLinkPlugin, options: { matchers: MATCHERS } }, + ], + }, +} as Record; + +/** Theme used to display contents of Editor */ +const theme = { + root: styles['editor-root'], + image: styles['editor-image'], + link: styles['editor-link'], + text: { + bold: styles['bold'], + italic: styles['italic'], + underline: styles['underline'], + strikethrough: styles['strikethrough'], + code: styles['editor-code'], + }, + quote: styles['editor-quote'], + hr: styles['editor-horizontal-rule'], + list: { + checklist: styles['editor-checklist'], + listitem: styles['editor-list-item'], + listitemChecked: styles['editor-list-item-checked'], + listitemUnchecked: styles['editor-list-item-unchecked'], + ol: styles['editor-ordered-list'], + ul: styles['editor-unordered-list'], + nested: { + listitem: styles['editor-list-item-nested'], + }, + }, + table: styles['editor-table'], + tableCell: styles['editor-table-cell'], + tableCellHeader: styles['editor-table-cell-header'], + tableRow: styles['editor-table-row'], +} satisfies EditorThemeClasses; + +interface LexicalTextEditorProps { + /** + * The feature bundle to use: custom nodes from used in {@link InitialConfigType} and lexical plugins. + * This property should not be edited after the component is mounted as Lexical does not support dynamic + * node list changes. You can use custom bundles if registered with {@link $registerBundle}. + * + * Predefined bundles: + * - `@etuutt/simple`: No extra nodes or plugins, only basic rich text features: bold, italic, underline, + * strikethrough and alignment + * - `@etuutt/full`: All available nodes and plugins provided by EtuUTT: including images, tables, code blocks, + * colored text, links, lists, horizontal rules, markdown support, quotes, headings, indentation. + * @default '@etuutt/simple' + */ + bundle?: string | '@etuutt/simple' | '@etuutt/full'; + /** Whether the Editor should handle inputs and events. This property can be updated at anytime. */ + disabled?: boolean; + /** The text (or ReactNode) to display when Editor is empty and disabled (property disabled set to true) */ + emptyText?: string; + /** The text (or ReactNode) to display when Editor is empty and enabled (no property disabled=true) */ + placeholder: string; + /** The initial state of the Editor, in Lexical's JSON format */ + initialState?: string; + /** Callback called when the content of the Editor changes, providing the new state in Lexical's JSON format */ + onChange?: (state: string) => void; + /** + * A ref to a hook to update Editor state (contents). This hook must be only used when setting different content, + * not for regular updated sent by `onChange` as state is already retained by Lexical (and it would be a bummer to + * recompute the whole state for every change). + */ + setStateRef?: MutableRefObject<(s: string) => void>; +} + +/** + * A Rich Text Editor component based on {@link https://lexical.dev Lexical}, with an onboarded toolbar + * and support for plugins and custom nodes. + * + * Lexical does not support dynamic node list changes, so the `bundle` property should not be changed + * after the component is mounted. Use {@link $registerBundle} to create and register custom bundles + * of nodes and plugins to use in the Editor. + * + * As Lexical does not use React nodes, the content of the editor is not managed through React state + * (it is managed through lexical state). The state you provide is an **initial state only**. To get + * the content of the editor, you can use the `onChange` callback from the props. + * + * @example + * + */ +function LexicalTextEditor({ + bundle = '@etuutt/simple', + placeholder, + emptyText, + disabled = false, + initialState, + onChange, + setStateRef, +}: LexicalTextEditorProps) { + const { nodes, plugins } = EDITOR_BUNDLES[bundle]; + const initialConfig = { + namespace: 'EtuUTT Front Editor', + theme, + onError: console.error, + nodes, + editorState: initialState, + } satisfies InitialConfigType; + + return ( + + {!disabled && } +
+ {(disabled && emptyText) || placeholder}
} + /> + } + ErrorBoundary={LexicalErrorBoundary} + /> + + !disabled && onChange?.(JSON.stringify(state))} /> + + {plugins.map((Plugin, index) => + typeof Plugin === 'function' ? : , + )} +
+ + ); +} + +export default LexicalTextEditor; + +/** + * Use this function to ensure `str` can be used in a {@link LexicalTextEditor} + * (either in the `initialState` prop or through the `setStateRef` hook) + */ +export function $makeJson(str: string) { + try { + JSON.parse(str); + return str; + } catch { + return ( + `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"${str.replaceAll(/\\/g, '\\\\').replaceAll(/"/g, '\\"')}",` + + `"type":"color-text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],` + + `"direction":"ltr","format":"","indent":0,"type":"root","version":1}}` + ); + } +} diff --git a/src/components/UI/Link.tsx b/src/components/UI/Link.tsx index 3f527fe..65c4a45 100644 --- a/src/components/UI/Link.tsx +++ b/src/components/UI/Link.tsx @@ -2,21 +2,21 @@ import styles from './Link.module.scss'; import { ReactNode } from 'react'; import ReactLink from 'next/link'; import { Url } from 'next/dist/shared/lib/router/router'; -import ExternalLink from '@/icons/ExternalLink'; +import { IconExternalLink } from 'obra-icons-react'; export default function Link({ children, href, className = '', noStyle = false, - newTab = false, + external = false, disabled = false, }: { children?: ReactNode; href: Url; className?: string; noStyle?: boolean; - newTab?: boolean; + external?: boolean; disabled?: boolean; }) { return disabled ? ( @@ -27,10 +27,10 @@ export default function Link({ + target={external ? '_blank' : '_self'} + rel={external ? 'noopener noreferrer' : undefined}> {children} - {external ? : <>} + {external ? : <>} ); } diff --git a/src/components/UI/ModalForm.tsx b/src/components/UI/ModalForm.tsx index fe25048..215d4ae 100644 --- a/src/components/UI/ModalForm.tsx +++ b/src/components/UI/ModalForm.tsx @@ -1,7 +1,7 @@ import { PropsWithoutRef, ReactNode, useEffect, useState } from 'react'; import { User } from '@/api/users/user.interface'; import { useAppTranslation } from '@/lib/i18n'; -import Icons from '@/icons'; +import { IconAdd, IconClose } from 'obra-icons-react'; import { UserCard } from '../users/UserCard'; import UserSelector from '../users/UserSelector'; import Input from './Input'; @@ -148,7 +148,7 @@ export function ModalForm({ fields, window, onSubmit, o
{window.title}
- +
@@ -223,7 +223,7 @@ function StringFormPart({ [fieldKey]: state === optionValue ? '' : optionValue, }) }> - {state === optionValue ? : } + {state === optionValue ? : } {optionLabel} ); @@ -270,7 +270,7 @@ function UserFormPart({ <> @@ -309,7 +309,7 @@ function StringListFormPart({ : [...state, optionValue], }) }> - {state.includes(optionValue) ? : } + {state.includes(optionValue) ? : } {optionLabel} ); diff --git a/src/components/assos/AssoRole.module.scss b/src/components/assos/AssoRole.module.scss index b5aef6a..6ad411f 100644 --- a/src/components/assos/AssoRole.module.scss +++ b/src/components/assos/AssoRole.module.scss @@ -6,7 +6,7 @@ .actionRow { display: flex; flex-flow: row wrap; - gap: 0.25em; + gap: 0.5ch; :nth-child(1) { flex: 2; @@ -19,7 +19,8 @@ border-radius: 50%; padding: 5px; position: absolute; - left: -28px; + left: -31px; + top: -0.05em; width: 15px; height: 15px; box-sizing: content-box; @@ -35,8 +36,8 @@ .members { display: flex; flex-flow: row wrap; - gap: 1em; - margin-top: 0.5em; + gap: 2ch; + margin-top: 1ch; .oldMember { opacity: 0.55; @@ -50,12 +51,12 @@ .pictureContainer { display: flex; flex-flow: row nowrap; - gap: 0.5em; + gap: 1ch; align-items: center; img { - width: 1.625em; - height: 1.625em; + width: 1.625rem; + height: 1.625rem; border-radius: 50%; object-fit: cover; display: flex; diff --git a/src/components/assos/AssoRole.tsx b/src/components/assos/AssoRole.tsx index 3c63547..1668aca 100644 --- a/src/components/assos/AssoRole.tsx +++ b/src/components/assos/AssoRole.tsx @@ -2,9 +2,9 @@ import { PropsWithoutRef, useState } from 'react'; import { Role, Member } from '@/api/assos/member.interface'; import { useAppTranslation } from '@/lib/i18n'; import styles from './AssoRole.module.scss'; -import Icons from '@/icons'; import Input from '../UI/Input'; import Link from '../UI/Link'; +import { IconCheck, IconCrown, IconDelete, IconEdit, IconUserAdd, IconUserCross } from 'obra-icons-react'; import DisableableButton from '../UI/DisableableButton'; /** @@ -58,7 +58,7 @@ export function AssoRole({

{role.isPresident && (
- +
)}
@@ -72,14 +72,14 @@ export function AssoRole({ createAssoMember(role.id)} disabled={!hasEditMembersPermission} - disabledTooltip={t('assos:no.permission.edit.member')}> - + disabledTooltip={t('assos:member.edit.no-permission')}> + deleteAssoRole(role.id)} disabled={!hasEditRolesPermission || role.isPresident} - disabledTooltip={t('assos:no.permission.edit.member')}> - + disabledTooltip={t('assos:member.edit.no-permission')}> + { @@ -92,8 +92,8 @@ export function AssoRole({ } }} disabled={!hasEditRolesPermission} - disabledTooltip={t('assos:no.permission.edit.member')}> - {editing ? : } + disabledTooltip={t('assos:member.edit.no-permission')}> + {editing ? : } )} @@ -138,14 +138,14 @@ export function AssoRole({ updateAssoMember(member, role.id)} disabled={!hasEditMembersPermission} - disabledTooltip={t('assos:no.permission.edit.member')}> - + disabledTooltip={t('assos:member.edit.no-permission')}> + deleteAssoMember(member.id)} disabled={!hasEditMembersPermission} - disabledTooltip={t('assos:no.permission.edit.member')}> - + disabledTooltip={t('assos:member.edit.no-permission')}> + )} diff --git a/src/components/homeWidgets/DailyTimetableWidget.tsx b/src/components/homeWidgets/DailyTimetableWidget.tsx index c64e48b..1fd2658 100644 --- a/src/components/homeWidgets/DailyTimetableWidget.tsx +++ b/src/components/homeWidgets/DailyTimetableWidget.tsx @@ -5,7 +5,7 @@ import { /*GetDailyTimetableResponseDto,*/ TimetableEvent } from '@/api/users/ge // import { useAPI } from '@/api/api'; import { format } from 'date-fns'; import * as locale from 'date-fns/locale'; -import Icons from '@/icons'; +import { IconChevronLeft, IconChevronRight } from 'obra-icons-react'; import Button from '@/components/UI/Button'; import { WidgetLayout } from '@/components/homeWidgets/WidgetLayout'; import { useAppTranslation } from '@/lib/i18n'; @@ -86,13 +86,13 @@ export default function DailyTimetableWidget() { subtitle={t('homepage:dailyTimetable.subtitle')}>
{format(selectedDate, `cccc d MMMM${selectedDate.getFullYear() === new Date().getFullYear() ? '' : ' yyyy'}`, { locale: locale.fr, })}
diff --git a/src/components/homeWidgets/UEBrowserWidget.tsx b/src/components/homeWidgets/UEBrowserWidget.tsx index 93b60ee..e164107 100644 --- a/src/components/homeWidgets/UEBrowserWidget.tsx +++ b/src/components/homeWidgets/UEBrowserWidget.tsx @@ -3,7 +3,7 @@ import Input from '@/components/UI/Input'; import { useState } from 'react'; import { WidgetLayout } from '@/components/homeWidgets/WidgetLayout'; import { useRouter } from 'next/navigation'; -import Icons from '@/icons'; +import { IconBook } from 'obra-icons-react'; export default function UEBrowserWidget() { const { t } = useAppTranslation(); @@ -14,7 +14,7 @@ export default function UEBrowserWidget() { router.push(`/ues?q=${search}`)} /> diff --git a/src/components/homeWidgets/UserBrowserWidget.tsx b/src/components/homeWidgets/UserBrowserWidget.tsx index 5e91739..5fb16cb 100644 --- a/src/components/homeWidgets/UserBrowserWidget.tsx +++ b/src/components/homeWidgets/UserBrowserWidget.tsx @@ -3,7 +3,7 @@ import Input from '@/components/UI/Input'; import { useState } from 'react'; import { WidgetLayout } from '@/components/homeWidgets/WidgetLayout'; import { useRouter } from 'next/navigation'; -import Icons from '@/icons'; +import { IconUser } from 'obra-icons-react'; export default function UserBrowserWidget() { const { t } = useAppTranslation(); @@ -14,7 +14,7 @@ export default function UserBrowserWidget() { router.push(`/users?q=${search}`)} /> diff --git a/src/components/homeWidgets/WidgetRenderer.tsx b/src/components/homeWidgets/WidgetRenderer.tsx index b939bad..ecdb32f 100644 --- a/src/components/homeWidgets/WidgetRenderer.tsx +++ b/src/components/homeWidgets/WidgetRenderer.tsx @@ -2,7 +2,7 @@ import styles from './WidgetRenderer.module.scss'; import { useEffect, useMemo, useRef } from 'react'; import { isClientSide } from '@/utils/environment'; import { BoundingBox, collidesWith, gridSize, WidgetInstance, WIDGETS } from '@/module/homepage'; -import Menu from '@/icons/Menu'; +import { IconMenu } from 'obra-icons-react'; import Button from '@/components/UI/Button'; const GAP_SIZE = 15; @@ -243,7 +243,7 @@ export default function WidgetRenderer({ {modifyingLayout && (
)} diff --git a/src/components/ues/ExamList.tsx b/src/components/ues/ExamList.tsx index 20d76ed..3537bcf 100644 --- a/src/components/ues/ExamList.tsx +++ b/src/components/ues/ExamList.tsx @@ -7,10 +7,7 @@ import { AnnalType, } from '@/api/annals/annal.interface'; import { openAnnalInNewTab } from '@/api/annals/fetchAnnals'; -import CircleCheck from '@/icons/CircleCheck'; -import CircleWarning from '@/icons/CircleWarning'; -import Clock from '@/icons/Clock'; -import Trash from '@/icons/Trash'; +import { IconCircleCheck, IconCircleWarning, IconClock4Alt, IconDelete } from 'obra-icons-react'; import { useConnectedUser, UserType } from '@/module/session'; import Button from '../UI/Button'; import Tooltip from '../UI/Tooltip'; @@ -19,10 +16,10 @@ import { useAppTranslation } from '@/lib/i18n'; import { useAPI } from '@/api/api'; function getIcon(status: AnnalStatus) { - if (status === 'deleted') return ; - if (status === 'processing') return ; - if (status === 'unverified') return ; - return ; + if (status === 'deleted') return ; + if (status === 'processing') return ; + if (status === 'unverified') return ; + return ; } export default function ExamList({ diff --git a/src/components/users/UserCard.module.scss b/src/components/users/UserCard.module.scss index 98a0e01..e3ee4b9 100644 --- a/src/components/users/UserCard.module.scss +++ b/src/components/users/UserCard.module.scss @@ -3,14 +3,16 @@ .user { background: white; border-radius: 5px; - padding: 0.5em; + padding: 1ch; display: flex; flex-flow: row nowrap; align-items: center; - gap: 0.5em; + gap: 1ch; user-select: none; .avatar { + display: block; + position: relative; width: 2em; height: 2em; border-radius: 50%; @@ -19,18 +21,29 @@ display: flex; align-items: center; justify-content: center; + overflow: hidden; font-size: 1.2em; background: $ung-light-blue; color: $very-light-gray; + user-select: none; + + img { + position: absolute; + object-fit: cover; + top: 0; + left: 0; + width: 100%; + height: 100%; + } } .info { display: flex; flex-flow: column nowrap; - gap: 0.1em; + gap: 0.2ch; color: $ung-dark-grey; - .cursus { + :nth-child(2) { font-style: italic; opacity: 0.8; } diff --git a/src/components/users/UserSelector.module.scss b/src/components/users/UserSelector.module.scss index 781238c..5e3a2fa 100644 --- a/src/components/users/UserSelector.module.scss +++ b/src/components/users/UserSelector.module.scss @@ -3,12 +3,12 @@ .userSelector { display: flex; flex-flow: column nowrap; - gap: 0.5em; + gap: 1ch; .resultPool { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); - gap: 0.5em; + gap: 1ch; position: relative; min-height: 2em; diff --git a/src/components/users/UserSelector.tsx b/src/components/users/UserSelector.tsx index 9a0eace..f44b4ce 100644 --- a/src/components/users/UserSelector.tsx +++ b/src/components/users/UserSelector.tsx @@ -13,6 +13,7 @@ export default function UserSelector({ const { t } = useAppTranslation(); const { items, updateFilters } = useUsers(); + const [searchString, setSearchString] = useState(''); useEffect(() => { diff --git a/src/icons/Add.tsx b/src/icons/Add.tsx deleted file mode 100644 index 3d5e07b..0000000 --- a/src/icons/Add.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; - -export default function Add() { - return ( - - - - ); -} diff --git a/src/icons/Book.tsx b/src/icons/Book.tsx deleted file mode 100644 index b85b7bf..0000000 --- a/src/icons/Book.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; - -export default function Book() { - return ( - - - - ); -} diff --git a/src/icons/Caret.tsx b/src/icons/Caret.tsx deleted file mode 100644 index df3e075..0000000 --- a/src/icons/Caret.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; - -export default function Caret() { - return ( - - - - ); -} diff --git a/src/icons/CircleCheck.tsx b/src/icons/CircleCheck.tsx deleted file mode 100644 index a4f9dfe..0000000 --- a/src/icons/CircleCheck.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; - -export default function CircleCheck() { - return ( - - - - ); -} diff --git a/src/icons/CircleWarning.tsx b/src/icons/CircleWarning.tsx deleted file mode 100644 index 7f7c3be..0000000 --- a/src/icons/CircleWarning.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react'; - -export default function CircleWarning() { - return ( - - - - - ); -} diff --git a/src/icons/Clock.tsx b/src/icons/Clock.tsx deleted file mode 100644 index 4242b20..0000000 --- a/src/icons/Clock.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; - -export default function Clock() { - return ( - - - - ); -} diff --git a/src/icons/Close.tsx b/src/icons/Close.tsx deleted file mode 100644 index 2cb725e..0000000 --- a/src/icons/Close.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; - -export default function Close() { - return ( - - - - ); -} diff --git a/src/icons/Collapse.tsx b/src/icons/Collapse.tsx deleted file mode 100644 index ae8724f..0000000 --- a/src/icons/Collapse.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react'; - -export default function Collapse() { - return ( - - - - ); -} diff --git a/src/icons/Comment.tsx b/src/icons/Comment.tsx deleted file mode 100644 index b670680..0000000 --- a/src/icons/Comment.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; - -export default function Comment() { - return ( - - - - ); -} diff --git a/src/icons/Confirm.tsx b/src/icons/Confirm.tsx deleted file mode 100644 index 70bb7c9..0000000 --- a/src/icons/Confirm.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; - -export default function Confirm() { - return ( - - - - ); -} diff --git a/src/icons/Copy.tsx b/src/icons/Copy.tsx deleted file mode 100644 index 204e64d..0000000 --- a/src/icons/Copy.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; - -// https://www.svgrepo.com/svg/309480/copy -export default function Copy() { - return ( - - ic_fluent_copy_24_regular - Created with Sketch. - - - - - - - ); -} diff --git a/src/icons/Crown.tsx b/src/icons/Crown.tsx deleted file mode 100644 index 97d16b3..0000000 --- a/src/icons/Crown.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react'; - -export default function Crown() { - return ( - - - - - ); -} diff --git a/src/icons/Edit.tsx b/src/icons/Edit.tsx deleted file mode 100644 index 0e67de4..0000000 --- a/src/icons/Edit.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; - -export default function Edit() { - return ( - - - - ); -} diff --git a/src/icons/Enter.tsx b/src/icons/Enter.tsx deleted file mode 100644 index c70f07d..0000000 --- a/src/icons/Enter.tsx +++ /dev/null @@ -1,14 +0,0 @@ -export default function Enter({ className }: { className?: string }) { - return ( - - - - - ); -} diff --git a/src/icons/ExternalLink.tsx b/src/icons/ExternalLink.tsx deleted file mode 100644 index 10418ed..0000000 --- a/src/icons/ExternalLink.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; - -export default function ExternalLink({ className }: { className?: string }) { - return ( - - - - ); -} diff --git a/src/icons/EyeOff.tsx b/src/icons/EyeOff.tsx deleted file mode 100644 index a1a2890..0000000 --- a/src/icons/EyeOff.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react'; - -export default function EyeOff() { - return ( - - - - - ); -} diff --git a/src/icons/EyeOn.tsx b/src/icons/EyeOn.tsx deleted file mode 100644 index 8090462..0000000 --- a/src/icons/EyeOn.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react'; - -export default function EyeOn() { - return ( - - - - - ); -} diff --git a/src/icons/Home.tsx b/src/icons/Home.tsx deleted file mode 100644 index 2d0541c..0000000 --- a/src/icons/Home.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; - -export default function Home() { - return ( - - - - ); -} diff --git a/src/icons/Language.tsx b/src/icons/Language.tsx deleted file mode 100644 index 83d3a1c..0000000 --- a/src/icons/Language.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; - -export default function Language() { - return ( - - - - ); -} diff --git a/src/icons/LeftArrow.tsx b/src/icons/LeftArrow.tsx deleted file mode 100644 index 4fac35b..0000000 --- a/src/icons/LeftArrow.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export default function LeftChevron() { - return ( - - - - ); -} diff --git a/src/icons/LeftChevron.tsx b/src/icons/LeftChevron.tsx deleted file mode 100644 index 14f6e0d..0000000 --- a/src/icons/LeftChevron.tsx +++ /dev/null @@ -1,11 +0,0 @@ -export default function LeftChevron() { - return ( - - - - ); -} diff --git a/src/icons/LinkExternal.tsx b/src/icons/LinkExternal.tsx deleted file mode 100644 index aa3b878..0000000 --- a/src/icons/LinkExternal.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; - -export default function LinkExternal() { - return ( - - - - ); -} diff --git a/src/icons/Login.tsx b/src/icons/Login.tsx deleted file mode 100644 index e1c46ae..0000000 --- a/src/icons/Login.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; - -export default function Login() { - return ( - - - - ); -} diff --git a/src/icons/Logout.tsx b/src/icons/Logout.tsx deleted file mode 100644 index 7561fd3..0000000 --- a/src/icons/Logout.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; - -export default function Logout() { - return ( - - - - ); -} diff --git a/src/icons/Mail.tsx b/src/icons/Mail.tsx deleted file mode 100644 index e143976..0000000 --- a/src/icons/Mail.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; - -export default function Mail() { - return ( - - - - ); -} diff --git a/src/icons/Menu.tsx b/src/icons/Menu.tsx deleted file mode 100644 index 4e70719..0000000 --- a/src/icons/Menu.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; - -export default function Menu({ className = '' }: { className?: string }) { - return ( - - - - ); -} diff --git a/src/icons/Phone.tsx b/src/icons/Phone.tsx deleted file mode 100644 index db24ea7..0000000 --- a/src/icons/Phone.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; - -export default function Phone() { - return ( - - - - ); -} diff --git a/src/icons/RightChevron.tsx b/src/icons/RightChevron.tsx deleted file mode 100644 index 6b6086f..0000000 --- a/src/icons/RightChevron.tsx +++ /dev/null @@ -1,11 +0,0 @@ -export default function RightChevron() { - return ( - - - - ); -} diff --git a/src/icons/Star.tsx b/src/icons/Star.tsx deleted file mode 100644 index 28a73cf..0000000 --- a/src/icons/Star.tsx +++ /dev/null @@ -1,12 +0,0 @@ -// Modified from the original source : https://iconduck.com/icons/69953/favorite -// The path was doubled to make the inside only be the contour, the inside path was removed -export default function Star() { - return ( - - - - ); -} diff --git a/src/icons/Trash.tsx b/src/icons/Trash.tsx deleted file mode 100644 index 21815ed..0000000 --- a/src/icons/Trash.tsx +++ /dev/null @@ -1,14 +0,0 @@ -export default function Trash({ className }: { className?: string }) { - return ( - - - - - ); -} diff --git a/src/icons/User.tsx b/src/icons/User.tsx deleted file mode 100644 index 93d0b54..0000000 --- a/src/icons/User.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; - -export default function User({ className = '' }: { className?: string }) { - return ( - - - - ); -} diff --git a/src/icons/UserAdd.tsx b/src/icons/UserAdd.tsx deleted file mode 100644 index 349f312..0000000 --- a/src/icons/UserAdd.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; - -export default function UserAdd() { - return ( - - {' '} - - ); -} diff --git a/src/icons/UserRemove.tsx b/src/icons/UserRemove.tsx deleted file mode 100644 index 3211946..0000000 --- a/src/icons/UserRemove.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; - -export default function UserRemove() { - return ( - - - - ); -} diff --git a/src/icons/Users.tsx b/src/icons/Users.tsx deleted file mode 100644 index 193f260..0000000 --- a/src/icons/Users.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; - -export default function Users() { - return ( - - - - ); -} diff --git a/src/icons/index.ts b/src/icons/index.ts index a21f7f0..972f3a9 100644 --- a/src/icons/index.ts +++ b/src/icons/index.ts @@ -1,83 +1,15 @@ -import Book from '@/icons/Book'; -import Caret from '@/icons/Caret'; -import Collapse from '@/icons/Collapse'; -import Home from '@/icons/Home'; -import LeftArrow from '@/icons/LeftArrow'; -import LeftChevron from '@/icons/LeftChevron'; -import Menu from '@/icons/Menu'; -import RightChevron from '@/icons/RightChevron'; -import Star from '@/icons/Star'; -import Trash from '@/icons/Trash'; -import User from '@/icons/User'; -import Users from '@/icons/Users'; -import CircleCheck from './CircleCheck'; -import CircleWarning from './CircleWarning'; -import Clock from './Clock'; -import Comment from './Comment'; -import Copy from './Copy'; -import Enter from './Enter'; -import ExternalLink from './ExternalLink'; -import Language from './Language'; import Loader from './Loader'; -import Login from './Login'; import LogoEtu from './LogoEtu'; import LogoUNG from './LogoUNG'; -import Logout from './Logout'; import LogoUTT from './LogoUTT'; import Rotate from './Rotate'; -import Mail from './Mail'; -import Phone from './Phone'; -import LinkExternal from './LinkExternal'; -import Crown from './Crown'; -import Add from './Add'; -import Close from './Close'; -import Edit from './Edit'; -import EyeOff from './EyeOff'; -import EyeOn from './EyeOn'; -import Confirm from './Confirm'; -import UserAdd from './UserAdd'; -import UserRemove from './UserRemove'; const Icons = { - Add, - Book, - Caret, - CircleCheck, - CircleWarning, - Clock, - Close, - Collapse, - Comment, - Confirm, - Crown, - Edit, - Enter, - ExternalLink, - EyeOff, - EyeOn, - Home, - Language, - LeftArrow, - LeftChevron, - LinkExternal, Loader, - Login, LogoEtu, LogoUNG, LogoUTT, - Logout, - Mail, - Menu, - Phone, - Star, - Trash, - RightChevron, Rotate, - User, - UserAdd, - UserRemove, - Users, - Copy, }; export default Icons; diff --git a/src/module/navbar.ts b/src/module/navbar.ts index 76e07db..f2f7cc5 100644 --- a/src/module/navbar.ts +++ b/src/module/navbar.ts @@ -1,9 +1,9 @@ import { MenuItem } from '@/components/Navbar'; import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; import { AppThunk, RootState } from 'src/lib/store'; -import Icons from '@/icons'; import { LocalStorageNames } from '@/global'; import { useAppSelector } from '@/lib/hooks'; +import { IconBook, IconChevronRight, IconHome, IconUser, IconUsers } from 'obra-icons-react'; interface NavbarSlice { items: MenuItem[]; @@ -77,35 +77,35 @@ export const navbarSlice = createSlice({ initialState: { items: [ { - icon: Icons.Home, + icon: IconHome, name: 'common:navbar.home', path: '/', translate: true, needLogin: false, }, { - icon: Icons.User, + icon: IconUser, name: 'common:navbar.userBrowser', path: '/users', translate: true, needLogin: true, }, { - icon: Icons.Book, + icon: IconBook, name: 'common:navbar.uesBrowser', path: '/ues', translate: true, needLogin: false, }, { - icon: Icons.Users, + icon: IconUsers, name: 'common:navbar.associations', path: '/assos', translate: true, needLogin: false, }, { - icon: Icons.Caret, + icon: IconChevronRight, name: 'common:navbar.myUEs', translate: true, needLogin: true, @@ -129,7 +129,7 @@ export const navbarSlice = createSlice({ ], }, { - icon: Icons.Caret, + icon: IconChevronRight, name: 'common:navbar.myAssociations', translate: true, needLogin: true,