diff --git a/.env.dist b/.env.dist index 86d26c6..5e05eb8 100644 --- a/.env.dist +++ b/.env.dist @@ -6,7 +6,7 @@ NODE_ENV=development NEXT_PUBLIC_API_URL=http://localhost:3000 # The version of the API, will be concatenated to the API base URL. -NEXT_PUBLIC_API_VERSION=v1 +NEXT_PUBLIC_API_VERSION=1 # The timeout when doing API requests, in milliseconds. NEXT_PUBLIC_API_REQUEST_TIMEOUT=10000 diff --git a/package.json b/package.json index 2d9b768..429961c 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,18 @@ "lint:fix": "next lint --fix" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@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", @@ -19,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 8339dcc..934dcad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,42 @@ importers: .: dependencies: + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/modifiers': + specifier: ^9.0.0 + version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + '@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.27) + '@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) @@ -35,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.23.1 react: specifier: ^18.3.1 version: 18.3.1 @@ -92,7 +134,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)(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(@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-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) @@ -118,6 +160,34 @@ packages: resolution: {integrity: sha512-Nms86NXrsaeU9vbBJKni6gXiEXZ4CVpYVzEjDH9Sb8vmZ3UljyA1GSOJl/6LGPO8EHLuSF9H+IxNXHPX8QHJ4g==} engines: {node: '>=6.9.0'} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/modifiers@9.0.0': + resolution: {integrity: sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@eslint-community/eslint-utils@4.4.0': resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -136,6 +206,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.3': + resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} + + '@floating-ui/dom@1.7.4': + resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} + + '@floating-ui/react-dom@2.1.6': + resolution: {integrity: sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/react@0.27.16': + resolution: {integrity: sha512-9O8N4SeG2z++TSM8QA/KTeKFBVCNEz/AGS7gWPJf6KFRzmRWixFRnCnkPHRDwSVZW6QPDO6uT0P2SpWNKCc9/g==} + peerDependencies: + react: '>=17.0.0' + react-dom: '>=17.0.0' + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@humanwhocodes/config-array@0.11.14': resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} @@ -151,6 +242,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==} @@ -231,6 +396,9 @@ packages: resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@preact/signals-core@1.12.1': + resolution: {integrity: sha512-BwbTXpj+9QutoZLQvbttRg5x3l5468qaV2kufh+51yha1c53ep5dY4kTuZR35+3pAZxpfQerGJiQqg34ZNZ6uA==} + '@reduxjs/toolkit@2.2.5': resolution: {integrity: sha512-aeFA/s5NCG7NoJe/MhmwREJxRkDs0ZaSqt0MxhWUrwCf1UQXpwR87RROJEql0uAkLI6U7snBOYOcKw83ew3FPg==} peerDependencies: @@ -1164,6 +1332,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==} @@ -1209,6 +1380,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.114: + resolution: {integrity: sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==} + engines: {node: '>=16'} + hasBin: true + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -1342,6 +1521,9 @@ packages: resolution: {integrity: sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==} engines: {node: '>= 0.4'} + obra-icons-react@1.23.1: + resolution: {integrity: sha512-jbn/1zqBipS3aOd5tTb3JLjWz49pwA0NCEmc/GMvHJG75Tn+jp4KBI4BG032BfYEGl3sYld7RCcRByPle3uymg==} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -1417,6 +1599,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==} @@ -1442,6 +1628,11 @@ packages: peerDependencies: react: ^18.3.1 + react-error-boundary@6.0.0: + resolution: {integrity: sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==} + peerDependencies: + react: '>=16.13.1' + react-i18next@14.1.2: resolution: {integrity: sha512-FSIcJy6oauJbGEXfhUgVeLzvWBhIBIS+/9c6Lj4niwKZyGaGb4V4vUbATXSlsHJDXXB+ociNxqFNiFuV1gmoqg==} peerDependencies: @@ -1687,6 +1878,9 @@ packages: resolution: {integrity: sha512-HwOKAP7Wc5aRGYdKH+dw0PRRpbO841v2DENBtjnR5HFWoiNByAl7vrx3p0G/rCyYXQsrxqtX48TImFtPcIHSpQ==} engines: {node: ^14.18.0 || >=16.0.0} + tabbable@6.2.0: + resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} + tapable@2.2.1: resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} engines: {node: '>=6'} @@ -1814,6 +2008,10 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yjs@13.6.27: + resolution: {integrity: sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -1824,6 +2022,38 @@ snapshots: dependencies: regenerator-runtime: 0.14.1 + '@dnd-kit/accessibility@3.1.1(react@18.3.1)': + dependencies: + react: 18.3.1 + tslib: 2.6.2 + + '@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.6.2 + + '@dnd-kit/modifiers@9.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + tslib: 2.6.2 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + tslib: 2.6.2 + + '@dnd-kit/utilities@3.2.2(react@18.3.1)': + dependencies: + react: 18.3.1 + tslib: 2.6.2 + '@eslint-community/eslint-utils@4.4.0(eslint@8.57.0)': dependencies: eslint: 8.57.0 @@ -1847,6 +2077,31 @@ snapshots: '@eslint/js@8.57.0': {} + '@floating-ui/core@1.7.3': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.4': + dependencies: + '@floating-ui/core': 1.7.3 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/react-dom@2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/dom': 1.7.4 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@floating-ui/react@0.27.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/react-dom': 2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@floating-ui/utils': 0.2.10 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tabbable: 6.2.0 + + '@floating-ui/utils@0.2.10': {} + '@humanwhocodes/config-array@0.11.14': dependencies: '@humanwhocodes/object-schema': 2.0.3 @@ -1868,6 +2123,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.12.1 + 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.27)': + dependencies: + '@floating-ui/react': 0.27.16(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.27) + lexical: 0.37.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-error-boundary: 6.0.0(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.27)': + dependencies: + '@lexical/offset': 0.37.0 + '@lexical/selection': 0.37.0 + lexical: 0.37.0 + yjs: 13.6.27 + '@next/env@14.2.4': {} '@next/eslint-plugin-next@14.2.4': @@ -1918,6 +2334,8 @@ snapshots: '@pkgr/core@0.1.1': {} + '@preact/signals-core@1.12.1': {} + '@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 @@ -2497,12 +2915,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)(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(@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): 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)(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(@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-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) @@ -2532,7 +2950,7 @@ snapshots: dependencies: 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-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): 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) @@ -2552,7 +2970,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)(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-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 @@ -2564,7 +2982,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)(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): dependencies: debug: 3.2.7 optionalDependencies: @@ -2592,7 +3010,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)(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) hasown: 2.0.2 is-core-module: 2.13.1 is-glob: 4.0.3 @@ -3074,6 +3492,8 @@ snapshots: isexe@2.0.0: {} + isomorphic.js@0.2.5: {} + iterator.prototype@1.1.2: dependencies: define-properties: 1.2.1 @@ -3126,6 +3546,12 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lexical@0.37.0: {} + + lib0@0.2.114: + dependencies: + isomorphic.js: 0.2.5 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -3255,6 +3681,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.0.0 + obra-icons-react@1.23.1: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -3330,6 +3758,8 @@ snapshots: prettier@3.2.5: {} + prismjs@1.30.0: {} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -3360,6 +3790,11 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-error-boundary@6.0.0(react@18.3.1): + dependencies: + '@babel/runtime': 7.24.5 + 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 @@ -3633,6 +4068,8 @@ snapshots: '@pkgr/core': 0.1.1 tslib: 2.6.2 + tabbable@6.2.0: {} + tapable@2.2.1: {} tar-fs@2.1.1: @@ -3808,4 +4245,8 @@ snapshots: yallist@4.0.0: {} + yjs@13.6.27: + dependencies: + lib0: 0.2.114 + yocto-queue@0.1.0: {} diff --git a/public/locales/fr/assos.json.ts b/public/locales/fr/assos.json.ts index 890865e..512467c 100644 --- a/public/locales/fr/assos.json.ts +++ b/public/locales/fr/assos.json.ts @@ -2,8 +2,39 @@ // For more information, check the common.json.ts file export default { - "browser": "UTT Travail", - "filter.search": "Recherche dans le guide des assos", - "filter.search.title": "Recherche dans le guide des assos" + 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 ', + 'member.old.to': ' et ', + 'member.old.display': 'Afficher les anciens', + 'member.old.hide': 'Masquer les anciens', + 'member.edit': 'Modifier les membres', + 'member.edit.stop': 'Fermer', + 'member.role.add': 'Ajouter un rôle', + 'member.role.create.title': 'Créer un rôle', + 'member.role.create.submit': 'Créer', + 'member.role.create.label': 'Nom du rôle', + 'member.role.delete.title': 'Supprimer le rôle', + 'member.role.delete.label': + 'Êtes-vous sûr de vouloir supprimer ce rôle ? Cela supprimera toutes les adhésions à ce rôle ainsi que les anciens membres.', + 'member.role.delete.confirm': "J'ai compris", + 'member.role.delete.submit': 'Supprimer', + 'member.add.title': 'Ajouter un membre', + 'member.add.label.user': 'Nouveau membre', + 'member.add.label.role': 'Rôle', + 'member.add.label.permissions': 'Permissions', + 'member.add.label.endAt': "Date de fin d'adhésion", + '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', } as const; - diff --git a/public/locales/fr/common.json.ts b/public/locales/fr/common.json.ts index 484baac..94db31e 100644 --- a/public/locales/fr/common.json.ts +++ b/public/locales/fr/common.json.ts @@ -17,20 +17,24 @@ // } export default { - "filter.all": "Tous", - "filter.filters": "Filtres", - "input.editableText.modify": "Modifier", - "loading": "Chargement", - "navbar.addWidget": "Ajouter", - "navbar.associations": "Associations", - "navbar.home": "Accueil", - "navbar.myAssociations": "Mes Assos", - "navbar.myTimetable": "Mon EdT", - "navbar.myUEs": "Mes matières", - "navbar.profile": "Mon profil", - "navbar.uesBrowser": "Guide des UEs", - "navbar.userBrowser": "Trombinoscope", - "or": "Ou", - "results": "résultats", - "404": "404 - Page not found", -} as const; \ No newline at end of file + 'filter.all': 'Tous', + 'filter.filters': 'Filtres', + 'input.editableText.modify': 'Modifier', + loading: 'Chargement', + 'navbar.addWidget': 'Ajouter', + 'navbar.associations': 'Associations', + 'navbar.home': 'Accueil', + 'navbar.myAssociations': 'Mes Assos', + 'navbar.myTimetable': 'Mon EdT', + 'navbar.myUEs': 'Mes matières', + 'navbar.profile': 'Mon profil', + 'navbar.uesBrowser': 'Guide des UEs', + 'navbar.userBrowser': 'Trombinoscope', + or: 'Ou', + results: 'résultats', + confirm: 'Confirmer', + '404': '404 - Page not found', + '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/public/locales/fr/goTo.json.ts b/public/locales/fr/goTo.json.ts index b2c8489..2e9c755 100644 --- a/public/locales/fr/goTo.json.ts +++ b/public/locales/fr/goTo.json.ts @@ -2,9 +2,11 @@ // For more information, check the common.json.ts file export default { - "search": "Aller vers...", - "users.normal": "Trombinoscope", - "users.normal.keywords": "utilisateurs users personnes", - "ues.normal": "Guide des UEs", - "ues.normal.keywords": "matieres", + search: 'Aller vers...', + 'users.normal': 'Trombinoscope', + 'users.normal.keywords': 'utilisateurs users personnes', + 'ues.normal': 'Guide des UEs', + 'ues.normal.keywords': 'matieres', + 'assos.normal': 'Associations', + 'assos.normal.keywords': 'clubs vie assocative franck jacquemin', } as const; diff --git a/public/locales/fr/users.json.ts b/public/locales/fr/users.json.ts index 93dfd5f..66ba7e4 100644 --- a/public/locales/fr/users.json.ts +++ b/public/locales/fr/users.json.ts @@ -2,38 +2,42 @@ // For more information, check the common.json.ts file export default { - "search.title": "Trombinoscope", - "filter.global.title": "Générique", - "filter.global.placeholder": "Recherche générique", - "filter.firstName.title": "Prénom", - "filter.firstName.placeholder": "Recherche par prénom", - "filter.lastName.title": "Nom de famille", - "filter.lastName.placeholder": "Recherche par nom de famille", - "filter.nickname.title": "Surnom", - "filter.nickname.placeholder": "Recherche par surnom", - "nickname": "Surnom", - "sex": "Sexe", - "mailUTT": "Mail UTT", - "mailPersonal": "Mail personnel", - "facebook": "Facebook", - "phone": "Téléphone", - "website": "Site web", - "passions": "Passions", - "birthday": "Date de naissance", - "branch": "Branche", - "semester": "Semestre", - "branchOption": "Filière", - "generalInfo.tabName": "Informations", - "generalInfo.title": "Informations générales sur l'utilisateur", - "assos.tabName": "Associatif", - "assos.title": "Associations de {{name}}", - "assos.noAssos": "Cet utilisateur n'est membre d'aucune association.", - "profile.title": "Profil", - "firstName": "Prénom", - "lastName": "Nom", - "username": "Nom d'utilisateur", - "mail": "Adresse mail", - "password": "Mot de passe", - "password.confirmation": "Confirmation de mot de passe", + 'search.title': 'Trombinoscope', + 'filter.global.title': 'Générique', + 'filter.global.placeholder': 'Recherche générique', + 'filter.firstName.title': 'Prénom', + 'filter.firstName.placeholder': 'Recherche par prénom', + 'filter.lastName.title': 'Nom de famille', + 'filter.lastName.placeholder': 'Recherche par nom de famille', + 'filter.nickname.title': 'Surnom', + 'filter.nickname.placeholder': 'Recherche par surnom', + nickname: 'Surnom', + sex: 'Sexe', + mailUTT: 'Mail UTT', + mailPersonal: 'Mail personnel', + facebook: 'Facebook', + phone: 'Téléphone', + website: 'Site web', + passions: 'Passions', + birthday: 'Date de naissance', + branch: 'Branche', + semester: 'Semestre', + branchOption: 'Filière', + 'generalInfo.tabName': 'Informations', + 'generalInfo.title': "Informations générales sur l'utilisateur", + 'assos.tabName': 'Associatif', + 'assos.title': 'Associations de {{name}}', + 'assos.noAssos': "Cet utilisateur n'est membre d'aucune association.", + 'profile.title': 'Profil', + firstName: 'Prénom', + lastName: 'Nom', + username: "Nom d'utilisateur", + mail: 'Adresse mail', + password: 'Mot de passe', + 'password.confirmation': 'Confirmation de mot de passe', + 'selector.ui.noResult': 'Aucun résultat', + 'selector.ui.placeholder': 'Rechercher un utilisateur', + 'selector.ui.noName': 'Nom inconnu', + 'selector.ui.noCursus': 'Parcours inconnu', + 'modal.form.change': "Changer l'utilisateur", } as const; - diff --git a/src/api/api.ts b/src/api/api.ts index b124fb9..aaaf874 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -1,6 +1,11 @@ import { apiTimeout, apiUrl, apiVersion, etuuttWebApplicationId } from '@/utils/environment'; import { StatusCodes } from 'http-status-codes'; +export const computeApiURL = (path: string, version = apiVersion) => + `${apiUrl.slice(-1) === '/' ? apiUrl.slice(0, -1) : apiUrl}/v${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. @@ -60,8 +65,8 @@ type RawResponseType = T extends Date */ export class ResponseHandler< T, - R extends { [status in StatusCodes]?: any } & { fallback: any } & { - [status in ResponseError | 'success' | 'error' | 'failure']?: any; + R extends { [status in StatusCodes]?: unknown } & { fallback: unknown } & { + [status in ResponseError | 'success' | 'error' | 'failure']?: unknown; } = { fallback: undefined }, > { private readonly handlers = { fallback: () => undefined } as { @@ -137,27 +142,30 @@ async function internalRequestAPI( route: string, body: RequestType | null, timeoutMillis: number, - version: string, + version: number, isFile: true, applicationId: string, + forceCache: boolean, ): Promise>; async function internalRequestAPI( method: string, route: string, body: RequestType | null, timeoutMillis: number, - version: string, + version: number, isFile: boolean, applicationId: string, + forceCache: boolean, ): Promise>; async function internalRequestAPI( method: string, route: string, body: RequestType | null, timeoutMillis: number, - version: string, + version: number, isFile: boolean, applicationId: string, + forceCache: boolean, ): Promise> { // Generate headers const headers = new Headers(); @@ -173,21 +181,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(computeApiURL(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 }; @@ -236,13 +239,13 @@ function requestAPI( method: 'GET', route: string, body: RequestType | null, - params: { timeoutMillis?: number; version?: string; isFile: true; applicationId?: string }, + params: { timeoutMillis?: number; version?: number; 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?: number; isFile?: boolean; applicationId?: string; forceCache?: boolean }, ): ResponseHandler; function requestAPI( method: string, @@ -253,9 +256,12 @@ function requestAPI( version = apiVersion, isFile = false, applicationId = etuuttWebApplicationId, - }: { timeoutMillis?: number; version?: string; isFile?: boolean; applicationId?: string } = {}, + forceCache = false, + }: { timeoutMillis?: number; version?: number; isFile?: boolean; applicationId?: string; forceCache?: boolean } = {}, ): ResponseHandler { - return new ResponseHandler(internalRequestAPI(method, route, body, timeoutMillis, version, isFile, applicationId)); + return new ResponseHandler( + internalRequestAPI(method, route, body, timeoutMillis, version, isFile, applicationId, forceCache), + ); } // Set the authorization header with the given token for next requests @@ -273,24 +279,24 @@ export function useAPI(): API { return { get: ( route: string, - options: { timeoutMillis?: number; version?: string; isFile?: boolean } = {}, + options: { timeoutMillis?: number; version?: number; isFile?: boolean; forceCache?: boolean } = {}, ) => applyDefaultHandler(requestAPI('GET', route, null, options)), post: ( route: string, body = {} as RequestType, - options: { version?: string; isFile?: boolean; applicationId?: string } = {}, + options: { version?: number; isFile?: boolean; applicationId?: string } = {}, ) => applyDefaultHandler(requestAPI('POST', route, body, options)), put: ( route: string, body = {} as RequestType, - options: { version?: string; isFile?: boolean; applicationId?: string } = {}, + options: { version?: number; isFile?: boolean; applicationId?: string } = {}, ) => applyDefaultHandler(requestAPI('PUT', route, body, options)), patch: ( route: string, body = {} as RequestType, - options: { version?: string; isFile?: boolean } = {}, + options: { version?: number; isFile?: boolean } = {}, ) => applyDefaultHandler(requestAPI('PATCH', route, body, options)), - delete: (route: string, options: { version?: string } = {}) => + delete: (route: string, options: { version?: number } = {}) => applyDefaultHandler(requestAPI('DELETE', route, null, options)), }; } @@ -298,28 +304,28 @@ export function useAPI(): API { export interface API { get( route: string, - options: { timeoutMillis?: number; version?: string; isFile: true }, + options: { timeoutMillis?: number; version?: number; isFile: true; forceCache?: boolean }, ): DefaultResponseHandlerType; get( route: string, - options?: { timeoutMillis?: number; version?: string; isFile?: boolean }, + options?: { timeoutMillis?: number; version?: number; isFile?: boolean; forceCache?: boolean }, ): DefaultResponseHandlerType; post( route: string, body?: RequestType, - options?: { version?: string; isFile?: boolean; applicationId?: string }, + options?: { version?: number; isFile?: boolean; applicationId?: string }, ): DefaultResponseHandlerType; put( route: string, body?: RequestType, - options?: { version?: string; isFile?: boolean }, + options?: { version?: number; isFile?: boolean }, ): DefaultResponseHandlerType; patch: ( route: string, body?: RequestType, - options?: { version?: string; isFile?: boolean }, + options?: { version?: number; isFile?: boolean }, ) => DefaultResponseHandlerType; - delete(route: string, options?: { version?: string }): DefaultResponseHandlerType; + delete(route: string, options?: { version?: number }): DefaultResponseHandlerType; } /** diff --git a/src/api/assos/asso.interface.ts b/src/api/assos/asso.interface.ts index ae7465b..5922e7e 100644 --- a/src/api/assos/asso.interface.ts +++ b/src/api/assos/asso.interface.ts @@ -1,8 +1,27 @@ +export interface AssoOverview { + id: string; + name: string; + logo: string; + shortDescription: string; + president: { + role: { + name: string; + }; + user: { + firstName: string; + lastName: string; + }; + }; +} + export interface Asso { id: string; name: string; logo: string; - descriptionShortTranslation: string; + description: string; + mail: string; + phoneNumber: string; + website: string; president: { role: { name: string; @@ -13,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/createRole.ts b/src/api/assos/createRole.ts new file mode 100644 index 0000000..383ad8e --- /dev/null +++ b/src/api/assos/createRole.ts @@ -0,0 +1,13 @@ +import { API } from '@/api/api'; +import { Role } from './member.interface'; + +export type CreateRoleRequest = { + name: string; +}; +export type CreatedRole = Omit; + +export function createRole(api: API, assoId: string, name: string) { + return api.post(`/assos/${assoId}/roles`, { + name, + }); +} diff --git a/src/api/assos/deleteRole.ts b/src/api/assos/deleteRole.ts new file mode 100644 index 0000000..ee652f4 --- /dev/null +++ b/src/api/assos/deleteRole.ts @@ -0,0 +1,8 @@ +import { API } from '@/api/api'; +import { Role } from './member.interface'; + +export type DeletedRole = Omit; + +export function deleteRole(api: API, assoId: string, roleId: string) { + return api.delete(`/assos/${assoId}/roles/${roleId}`); +} diff --git a/src/api/assos/fetchAsso.hook.ts b/src/api/assos/fetchAsso.hook.ts new file mode 100644 index 0000000..5cb5a27 --- /dev/null +++ b/src/api/assos/fetchAsso.hook.ts @@ -0,0 +1,14 @@ +import { useEffect, useState } from 'react'; +import { useAPI } from '@/api/api'; +import { Asso } from '@/api/assos/asso.interface'; + +export function useAsso(assoId: string): [Asso, (asso: Asso) => void] { + const [asso, setAsso] = useState(null); + const api = useAPI(); + useEffect(() => { + api.get(`/assos/${assoId}`).on('success', (body) => { + setAsso(body); + }); + }, []); + return [asso!, setAsso]; +} diff --git a/src/api/assos/fetchAssoMembers.hook.ts b/src/api/assos/fetchAssoMembers.hook.ts new file mode 100644 index 0000000..c3a724d --- /dev/null +++ b/src/api/assos/fetchAssoMembers.hook.ts @@ -0,0 +1,14 @@ +import { Dispatch, SetStateAction, useEffect, useState } from 'react'; +import { useAPI } from '@/api/api'; +import { Role, RoleResponse } from './member.interface'; + +export function useMembers(assoId: string): [Role[], Dispatch>] { + const [roles, setRoles] = useState([]); + const api = useAPI(); + useEffect(() => { + api.get(`/assos/${assoId}/members`).on('success', (body) => { + setRoles(body.roles); + }); + }, []); + return [roles!, setRoles]; +} diff --git a/src/api/assos/manageMembers.ts b/src/api/assos/manageMembers.ts new file mode 100644 index 0000000..de6badf --- /dev/null +++ b/src/api/assos/manageMembers.ts @@ -0,0 +1,42 @@ +import { API } from '../api'; + +type Member = { + id: string; + userId: string; + roleId: string; + startAt: Date; + endAt: Date; +}; + +type MemberCreateRequest = { + roleId: string; + userId: string; + endAt: Date; + permissions: string[]; +}; + +type MemberUpdateRequest = Partial>; + +export function addMember( + api: API, + assoId: string, + roleId: string, + userId: string, + endAt: Date, + permissions: string[], +) { + return api.post(`/assos/${assoId}/members`, { + roleId, + userId, + endAt, + permissions, + }); +} + +export function updateMember(api: API, assoId: string, memberId: string, data: MemberUpdateRequest) { + return api.patch(`/assos/${assoId}/members/${memberId}`, data); +} + +export function deleteMember(api: API, assoId: string, memberId: string) { + return api.delete(`/assos/${assoId}/members/${memberId}`); +} diff --git a/src/api/assos/member.interface.ts b/src/api/assos/member.interface.ts new file mode 100644 index 0000000..6b317e8 --- /dev/null +++ b/src/api/assos/member.interface.ts @@ -0,0 +1,29 @@ +export interface Member { + id: string; + userId: string; + firstName: string; + lastName: string; + startAt: Date; + endAt: Date; + permissions: string[]; +} + +export interface Role { + id: string; + name: string; + position: number; + isPresident: boolean; + members: Member[]; +} + +export interface RoleResponse { + roles: Role[]; +} + +export interface RoleCreateRequest { + name: string; +} + +export interface RoleUpdateRequest extends RoleCreateRequest { + position: number; +} diff --git a/src/api/assos/searchAssos.hook.ts b/src/api/assos/searchAssos.hook.ts index cdf54da..0b7e378 100644 --- a/src/api/assos/searchAssos.hook.ts +++ b/src/api/assos/searchAssos.hook.ts @@ -1,14 +1,14 @@ import { useState } from 'react'; import { useAPI } from '@/api/api'; import { Pagination } from '@/api/api.interface'; -import { Asso } from '@/api/assos/asso.interface'; +import { AssoOverview } from '@/api/assos/asso.interface'; -export function useAssos(): [Asso[], number, (query: Record) => void] { - const [assos, setAssos] = useState([]); +export function useAssos(): [AssoOverview[], number, (query: Record) => void] { + const [assos, setAssos] = useState([]); const [total, setTotal] = useState(0); const api = useAPI(); const updateAssos = async (query: Record) => - api.get>(`/assos?${new URLSearchParams(query)}`).on('success', (body) => { + api.get>(`/assos?${new URLSearchParams(query)}`).on('success', (body) => { setAssos(body.items); setTotal(body.itemCount); }); diff --git a/src/api/assos/updateAsso.ts b/src/api/assos/updateAsso.ts new file mode 100644 index 0000000..d03d3dc --- /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/assos/updateRole.ts b/src/api/assos/updateRole.ts new file mode 100644 index 0000000..7cb50cd --- /dev/null +++ b/src/api/assos/updateRole.ts @@ -0,0 +1,18 @@ +import { API } from '@/api/api'; +import { Role, RoleResponse, RoleUpdateRequest } from './member.interface'; + +export async function updateRole( + api: API, + assoId: string, + roleId: string, + position: number, + name: string, +): Promise { + const res = await api + .put(`/assos/${assoId}/roles/${roleId}`, { + name, + position, + }) + .toPromise(); + return res?.roles; +} diff --git a/src/app/assos/[assoId]/page.tsx b/src/app/assos/[assoId]/page.tsx new file mode 100644 index 0000000..1afefc9 --- /dev/null +++ b/src/app/assos/[assoId]/page.tsx @@ -0,0 +1,447 @@ +'use client'; + +import { + IconAdd, + IconCall, + IconCheck, + IconClose, + IconEdit, + IconEmail, + IconExternalLink, + IconEye, + IconEyeOff, +} from 'obra-icons-react'; +import { useEffect, useRef, useState } from '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 Link from '@/components/UI/Link'; +import { useAppTranslation } from '@/lib/i18n'; +import Button from '@/components/UI/Button'; +import { useAppSelector } from '@/lib/hooks'; +import { deleteRole } from '@/api/assos/deleteRole'; +import { useAPI } from '@/api/api'; +import { VerticalSortDnd } from '@/components/UI/VerticalSortDnd'; +import { updateRole } from '@/api/assos/updateRole'; +import { Member } from '@/api/assos/member.interface'; +import { createRole } from '@/api/assos/createRole'; +import { addMember, deleteMember, updateMember } from '@/api/assos/manageMembers'; +import { User } from '@/api/users/user.interface'; +import { DataModalSchema, ModalCallbackType, ModalForm, WindowOptions } from '@/components/UI/ModalForm'; +import { AssoRole } from '@/components/assos/AssoRole'; +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 AssoDetailModalType = + | { user: 'user'; roleId: 'string'; permissions: 'stringList'; endAt: 'date' } + | { + roleId: 'string'; + permissions: 'stringList'; + endAt: 'date'; + } + | { confirmation: 'string' } + | { roleId: 'string' }; + +export default function AssoDetailPage() { + const params = useParams<{ assoId: string }>(); + const user = useAppSelector((state) => state.user); + 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>({}); + + 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(), + ); + setPermissions(permissions); + console.log('Current user permissions:'); + console.log(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; + const updatedRoles = await updateRole( + api, + asso!.id, + roleId, + data?.position ?? role.position, + data?.name ?? role.name, + ); + if (updatedRoles) + setMembers( + updatedRoles.map((role) => { + const legacyRole = members.find((r) => r.id === role.id); + return { ...role, members: legacyRole?.members ?? [] }; + }), + ); + }; + + // 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)); + }; + + const createAssoRole = async (name: string) => { + const createdRole = await createRole(api, asso!.id, name).toPromise(); + if (createdRole) setMembers([...members, { ...createdRole, members: [] }].sort((a, b) => a.position - b.position)); + }; + + const createAssoMember = async (roleId: string, endAt: Date, permissions: string[], user: User) => { + const newMembership = await addMember(api, asso!.id, roleId, user.id, endAt, permissions).toPromise(); + if (newMembership) { + setMembers( + members.map((role) => + role.id === roleId + ? { + ...role, + members: [ + ...role.members, + { ...newMembership, firstName: user.firstName, lastName: user.lastName, permissions: permissions }, + ], + } + : role, + ), + ); + } + }; + + const updateAssoMember = async ( + id: string, + data: Partial<{ endAt: Date; permissions: string[]; roleId: string }>, + roleId: string, + ) => { + if (data.roleId === roleId) delete data.roleId; // the api will refuse to update if roleId is the same + const updatedMembership = await updateMember(api, asso.id, id, data).toPromise(); + setMembers((members) => { + const affectedMembership = members + .find((role) => role.id === roleId) + ?.members.find((member) => member.id === updatedMembership?.id); + if (affectedMembership && updatedMembership) { + Object.assign(affectedMembership, updatedMembership); + if (data.roleId && roleId !== data.roleId) { + // Move member to another role + const oldRole = members.find((role) => role.id === roleId); + oldRole?.members.splice(oldRole.members.indexOf(affectedMembership!), 1); + members.find((role) => role.id === data.roleId)?.members.push(affectedMembership!); + } + } + return [...members]; // force rerender + }); + }; + + const deleteAssoMember = async (id: string) => { + const deletedMembership = await deleteMember(api, asso.id, id).toPromise(); + setMembers((members) => { + const affectedRole = members.find((role) => role.id === deletedMembership?.roleId); + const affectedMembership = affectedRole?.members.find((member) => member.id === deletedMembership?.id); + if (affectedMembership) Object.assign(affectedMembership, deletedMembership); + return [...members]; // force rerender + }); + }; + + /* Modal entrypoints */ + + const openModalForMemberCreation = (roleId: string) => { + setModalFormWindow({ title: t('assos:member.add.title'), submitText: t('assos:member.add.submit') }); + setModalForm({ + user: { type: 'user', label: t('assos:member.add.label.user'), required: true }, + roleId: { + type: 'string', + label: t('assos:member.add.label.role'), + options: members.map((role) => [role.name, role.id]), + required: true, + defaultValue: roleId, + }, + permissions: { + type: 'stringList', + label: t('assos:member.add.label.permissions'), + options: Array.from(permissions), + }, + endAt: { type: 'date', label: t('assos:member.add.label.endAt'), required: true }, + }); + }; + + const openModalForMemberUpdate = (member: Member, roleId: string) => { + setExtraModalData({ roleId, memberId: member.id }); + setModalFormWindow({ title: t('assos:member.edit.title'), submitText: t('assos:member.edit.submit') }); + setModalForm({ + roleId: { + type: 'string', + label: t('assos:member.edit.label.role'), + options: members.map((role) => [role.name, role.id]), + required: true, + defaultValue: roleId, + }, + permissions: { + type: 'stringList', + label: t('assos:member.edit.label.permissions'), + options: Array.from(permissions), + defaultValue: member.permissions, + }, + endAt: { type: 'date', label: t('assos:member.edit.label.endAt'), required: true, defaultValue: member.endAt }, + }); + }; + + const openModalForRoleDeletion = (roleId: string) => { + setExtraModalData({ roleId }); + setModalFormWindow({ + title: t('assos:member.role.delete.title'), + submitText: t('assos:member.role.delete.submit'), + }); + setModalForm({ + confirmation: { + type: 'string', + label: t('assos:member.role.delete.label'), + options: [t('assos:member.role.delete.confirm')], + required: true, + }, + }); + }; + + const openModalForRoleCreation = () => { + setModalFormWindow({ + title: t('assos:member.role.create.title'), + submitText: t('assos:member.role.create.submit'), + }); + setModalForm({ + roleId: { + type: 'string', + label: t('assos:member.role.create.label'), + required: true, + }, + }); + }; + + const handlePopupSubmit = (data: ModalCallbackType) => { + if ('user' in data && data.roleId && data.endAt && data.permissions) { + createAssoMember(data.roleId, data.endAt, data.permissions, data.user); + } else if ('permissions' in data && extraModalData.memberId && extraModalData.roleId && data.roleId && data.endAt) { + updateAssoMember( + extraModalData.memberId, + { endAt: data.endAt, permissions: data.permissions, roleId: data.roleId }, + extraModalData.roleId, + ); + } else if ('confirmation' in data && extraModalData.roleId) { + deleteAssoRole(extraModalData.roleId); + } else if ('roleId' in data) { + createAssoRole(data.roleId); + } + }; + + /* End of modal operations */ + + return ( + +
i).join(' ')}> + {permissions.has('manage_infos') && ( + + )} + updateAssoEdit({ logo: mediaId })} + /> +
+
+

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

+ {asso ? ( + updateAssoEdit({ description: { fr: state } })} + setStateRef={stateRef} + disabled={!editInfosMode} + /> + ) : ( + <> +
+
+
+
+ + )} +
+
+ {editInfosMode ? ( + <> + updateAssoEdit({ website })} + /> + updateAssoEdit({ mail })} /> + updateAssoEdit({ phoneNumber })} + /> + + ) : ( + <> + + +
{asso?.website?.replace(/^https?:\/\/(?:www\.)?/, '')}
+ + + +
{asso?.mail}
+ + + +
{asso?.phoneNumber}
+ + + )} +
+
+
+ {!!members.length && ( +
i).join(' ')}> +

+ {t('assos:member.list.title')} +
+ {permissions.has('manage_roles') && editMembersMode && ( + + )} + {!!permissions.size && ( + + )} + {!editMembersMode && ( + + )} +
+

+ updateAssoRole(id, { position: newIndex })} + inflater={({ item: role }) => ( + + )} + disabled={!editMembersMode || !permissions.has('manage_roles')}> +
+ )} + {modalForm && modalFormWindow && ( + + onSubmit={handlePopupSubmit} + onClose={() => { + setModalForm(null); + setModalFormWindow(null); + }} + window={modalFormWindow} + fields={modalForm} + /> + )} +
+ ); +} diff --git a/src/app/assos/[assoId]/style.module.scss b/src/app/assos/[assoId]/style.module.scss new file mode 100644 index 0000000..cf1935f --- /dev/null +++ b/src/app/assos/[assoId]/style.module.scss @@ -0,0 +1,146 @@ +@import '@/variables'; +@import '@/components/glimmer.scss'; + +.headerCard { + background: white; + padding: 3ch; + display: flex; + flex-flow: row nowrap; + 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: 2ch; + justify-content: space-between; + + h1:empty, + & > div > :empty, + .actionRow div:empty { + @extend .glimmer-animated; + width: 16ch; + height: 0.8em; + margin-top: 0.2em; + margin-bottom: 0.2em; + + &:nth-child(2) { + width: 48ch; + } + + &:nth-child(3) { + width: 36ch; + } + + &:nth-child(4) { + width: 62ch; + } + } + + .actionRow { + display: flex; + flex-flow: row wrap; + gap: 3ch; + + svg + div { + display: inline-block; + vertical-align: super; + margin-left: 0.5ch; + } + + :nth-child(1) div:empty { + width: 12ch; + } + :nth-child(2) div:empty { + width: 18ch; + } + :nth-child(3) div:empty { + width: 10ch; + } + } + } +} + +.membersCard { + background: white; + margin-top: 3ch; + padding: calc(3ch - 2px); + display: flex; + flex-flow: column nowrap; + gap: 1ch; + border-radius: 1ch; + border: 2px solid transparent; + + &.editMode { + border: 2px dashed $ung-light-blue; + background: rgba($color: $ung-light-blue, $alpha: 0.1); + + & > div { + cursor: grab; + background-color: color-mix(in srgb, $ung-light-blue 20%, $very-light-gray 80%); + border-radius: 10px; + } + + & > [aria-pressed] { + opacity: 0.3; + pointer-events: none; + } + } + + h2 { + display: flex; + flex-flow: row nowrap; + justify-content: space-between; + + .actionRow { + display: flex; + flex-flow: row wrap; + gap: 1ch; + align-items: center; + } + + .toggleOldMembers { + font-size: 0.8em; + padding: 4px 8px; + border-radius: calc(0.5em + 4px); + + svg { + margin-right: 0.5ch; + } + } + } + + > div { + padding: 1ch 10px; + } + + :has(> :not(path):nth-child(2):empty) { + display: none; + } + + &.editMode :has(> :not(path):nth-child(2):empty) { + display: block; + } +} diff --git a/src/app/assos/page.tsx b/src/app/assos/page.tsx index 0790c78..8f65046 100644 --- a/src/app/assos/page.tsx +++ b/src/app/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 @@ -44,7 +44,7 @@ export default function AssoPage() { itemFactory={({ item }) => (

{item?.name}

-

{item?.descriptionShortTranslation}

+

{item?.shortDescription}

)} getItemId={(asso) => asso.id} diff --git a/src/app/developers/application/page.tsx b/src/app/developers/application/page.tsx index 438d40d..7426030 100644 --- a/src/app/developers/application/page.tsx +++ b/src/app/developers/application/page.tsx @@ -1,6 +1,7 @@ 'use client'; import styles from './style.module.scss'; +import { IconCopy } from 'obra-icons-react'; import useApplications from '@/api/auth/applications/fetchApplications'; import Trash from '@/icons/Trash'; import Input from '@/components/UI/Input'; @@ -10,7 +11,6 @@ import createApplication from '@/api/auth/applications/createApplication'; import { useAPI } from '@/api/api'; import { useConnectedUser } from '@/module/user'; import updateApplicationToken from '@/api/auth/applications/updateToken'; -import Icons from '@/icons'; import Page from '@/components/utilities/Page'; export default function ApplicationsPage() { @@ -71,7 +71,7 @@ export default function ApplicationsPage() { {token}
diff --git a/src/app/ues/[code]/Comments.tsx b/src/app/ues/[code]/Comments.tsx index e2d6ef7..97cda6d 100644 --- a/src/app/ues/[code]/Comments.tsx +++ b/src/app/ues/[code]/Comments.tsx @@ -2,7 +2,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/user'; @@ -79,7 +79,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/ues/page.tsx b/src/app/ues/page.tsx index 944c57e..b1f789e 100644 --- a/src/app/ues/page.tsx +++ b/src/app/ues/page.tsx @@ -3,7 +3,7 @@ import styles from './style.module.scss'; import { createInputFilter } from '@/components/filteredSearch/InputFilter'; import { useUEs } from '@/api/ue/search'; import FilteredSearch, { FiltersDataType, GenericFiltersType } from '@/components/filteredSearch/FilteredSearch'; -import Icons from '@/icons'; +import { IconBook } from 'obra-icons-react'; import { createSelectFilter, SelectFilter } from '@/components/filteredSearch/SelectFilter'; import { ResultsList } from '@/components/ResultsList'; import { useAppTranslation } from '@/lib/i18n'; @@ -33,7 +33,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/components/Navbar.tsx b/src/components/Navbar.tsx index b773af3..3e4908f 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 } from '@/module/session'; import Button from './UI/Button'; import { usePageSettings } from '@/module/pageSettings'; @@ -18,7 +19,7 @@ import { LocalStorageNames } from '@/global'; * 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 @@ -121,7 +122,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} @@ -135,7 +136,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}
@@ -155,11 +156,11 @@ export default function Navbar() { EtuUTT
- +
- +
{/* NAVIGATION */} @@ -189,7 +190,7 @@ export default function Navbar() {
setLanguageSelectorOpen(!languageSelectorOpen)}> - +
@@ -217,7 +218,7 @@ export default function Navbar() {
- +
@@ -227,7 +228,7 @@ export default function Navbar() { {!loggedIn && (
- + Connexion
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..41d93a0 --- /dev/null +++ b/src/components/UI/Avatar.tsx @@ -0,0 +1,53 @@ +import { PropsWithoutRef } from 'react'; +import styles from './Avatar.module.scss'; +import { computeApiURL, useAPI } from '@/api/api'; +import { PartialUploadResponse } from './LexicalPlugins/ImageDropPlugin'; +import { useAppTranslation } from '@/lib/i18n'; +import { IconEdit } from 'obra-icons-react'; +import { ImageMedia } from './ImageMedia'; + +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 formData = new FormData(); + formData.append('file', file); + const uploadResponse = await api + .post< + FormData, + PartialUploadResponse + >(`/media/image?public=${!!isPublic}&preset=AVATAR`, formData, { isFile: true }) + .toPromise(); + if (uploadResponse?.id && onChange) onChange(uploadResponse.id); + }; + + return ( +
c).join(' ')}> + {editable && ( + <> + + + + )} + {name?.charAt(0) || '?'} + {localSrc && } +
+ ); +} diff --git a/src/components/UI/ImageMedia.tsx b/src/components/UI/ImageMedia.tsx new file mode 100644 index 0000000..a35fa58 --- /dev/null +++ b/src/components/UI/ImageMedia.tsx @@ -0,0 +1,53 @@ +import { computeApiURL, 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(computeApiURL('/media/image/'))) { + setSrc(worldSrc); + return; + } + api + .get(worldSrc.slice(computeApiURL('').length), { isFile: true, forceCache: true }) + .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..33c0571 --- /dev/null +++ b/src/components/UI/LexicalPlugins/ColorTextNode.tsx @@ -0,0 +1,77 @@ +import { + $getState, + $setState, + 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 { + static getType() { + return 'color-text'; + } + + static clone(node: ColorTextNode) { + return new ColorTextNode(node.__text, node.__key); + } + + 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..06daf5e --- /dev/null +++ b/src/components/UI/LexicalPlugins/ColorTextPlugin.tsx @@ -0,0 +1,56 @@ +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; + // Order is important here, we must start by the last one in case it is also the first one. + if ($isColorTextNode(colorNodes[lastIndex])) + colorNodes[lastIndex] = colorNodes[lastIndex].spliceText( + selection.isBackward() ? selection.anchor.offset : selection.focus.offset, + colorNodes[lastIndex].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..18c6ebc --- /dev/null +++ b/src/components/UI/LexicalPlugins/EnableDisablePlugin.tsx @@ -0,0 +1,16 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { MutableRefObject, PropsWithRef, useEffect } from 'react'; + +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) => editor.update(() => editor.setEditorState(editor.parseEditorState(s))); + }, [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..d788b7d --- /dev/null +++ b/src/components/UI/LexicalPlugins/ImageDropPlugin.tsx @@ -0,0 +1,111 @@ +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, computeApiURL, useAPI } from '@/api/api'; +import { useAppTranslation } from '@/lib/i18n'; +import styles from '../LexicalTextEditor.module.scss'; + +export const DRAGLEAVE_COMMAND = createCommand('DRAGLEAVE_COMMAND'); + +export interface PartialUploadResponse { + id: string; + width: number; + height: number; +} + +export async function uploadFile(file: File, api: API, editor: LexicalEditor) { + const formData = new FormData(); + formData.append('file', file); + const uploadResponse = await api + .post(`/media/image?public=true`, formData, { isFile: true }) + .toPromise(); + editor.update(() => { + $addUpdateTag(SKIP_SELECTION_FOCUS_TAG); + editor.dispatchCommand(INSERT_IMAGE_COMMAND, { + src: computeApiURL(`/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..8ac1125 --- /dev/null +++ b/src/components/UI/LexicalPlugins/ImageNode.tsx @@ -0,0 +1,110 @@ +import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection'; +import { 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 class ImageNode extends DecoratorNode { + __src: string; + __altText: string; + __width: number | 'inherit'; + __height: number | 'inherit'; + + static getType() { + return 'image'; + } + + static clone(node: ImageNode) { + return new ImageNode(node.__src, node.__altText, node.__width, node.__height, node.__key); + } + + constructor(src: string, altText?: string, width?: number | 'inherit', height?: number | 'inherit', key?: NodeKey) { + super(key); + this.__src = src; + this.__altText = altText || ''; + this.__width = width || 'inherit'; + this.__height = height || 'inherit'; + } + + 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: this.__src, + altText: this.__altText, + width: this.__width, + height: this.__height, + }; + } +} + +export function $createImageNode( + src: string, + altText?: string, + width?: number | 'inherit', + height?: number | 'inherit', + nodeKey?: NodeKey, +): ImageNode { + return new ImageNode(src, altText, width, height, nodeKey); +} + +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..a4676bb --- /dev/null +++ b/src/components/UI/LexicalPlugins/ImagePlugin.tsx @@ -0,0 +1,177 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { $wrapNodeInElement, mergeRegister } from '@lexical/utils'; +import { + $createParagraphNode, + $createRangeSelection, + $getSelection, + $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, ImageNode } 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: node.__altText, + height: node.__height, + width: node.__width, + src: node.__src, + 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) { + 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..341d9bd --- /dev/null +++ b/src/components/UI/LexicalPlugins/ToolbarPlugin.tsx @@ -0,0 +1,525 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { $getNearestNodeOfType, mergeRegister } from '@lexical/utils'; +import { + $addUpdateTag, + $createParagraphNode, + $findMatchingParent, + $getSelection, + $isElementNode, + $isRangeSelection, + $isRootOrShadowRoot, + 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'; + +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 $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; + } + + 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/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..38c71d6 --- /dev/null +++ b/src/components/UI/LexicalTextEditor.tsx @@ -0,0 +1,250 @@ +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; + +/** + * Registers a new feature bundle for the Lexical Text Editor. A bundle is a set of nodes and plugins + * that can be used in the Editor. + * @param name the name used to access to your bundle when intializing the {@link LexicalTextEditor} + * @param bundle the feature bundle containing nodes and plugins + */ +export function $registerBundle(name: string, bundle: RTEFeatureBundle) { + if (!(name in EDITOR_BUNDLES)) EDITOR_BUNDLES[name] = bundle; +} + +/** + * 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 409b7aa..8d29a89 100644 --- a/src/components/UI/Link.tsx +++ b/src/components/UI/Link.tsx @@ -8,14 +8,26 @@ export default function Link({ href, className = '', noStyle = false, + newTab = false, + disabled = false, }: { children?: ReactNode; href: Url; className?: string; noStyle?: boolean; + newTab?: boolean; + disabled?: boolean; }) { - return ( - + return disabled ? ( +
+ {children} +
+ ) : ( + {children} ); diff --git a/src/components/UI/ModalForm.module.scss b/src/components/UI/ModalForm.module.scss new file mode 100644 index 0000000..1095681 --- /dev/null +++ b/src/components/UI/ModalForm.module.scss @@ -0,0 +1,119 @@ +@import '@/variables'; + +.darkModal { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: rgba(0, 0, 0, 0.5); + transition: opacity 200ms ease-in-out; + + &.hidden { + opacity: 0; + pointer-events: none; + } +} + +.modal { + position: fixed; + right: 0; + top: 0; + width: 600px; + height: 100vh; + background-color: $light-gray; + box-shadow: -2px 0 5px rgba(0, 0, 0, 0.3); + transition: right 200ms ease-in-out; + + &.hidden { + right: -600px; + pointer-events: none; + } + + .title { + width: 100%; + padding: 1em; + background-color: $ung-dark-grey; + color: $very-light-gray; + font-weight: bold; + font-size: 1.2em; + display: flex; + flex-flow: row nowrap; + justify-content: space-between; + align-items: center; + gap: 1em; + + .close { + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + } + } + + .container { + display: flex; + flex-flow: column nowrap; + justify-content: space-between; + height: calc(100% - 4em); + + .confirm { + flex: 0 0 auto; + margin: 1em; + } + + .content { + flex: 1 1 auto; + display: flex; + flex-flow: column nowrap; + padding: 1em; + gap: 1em; + overflow: auto; + + .element { + .label { + margin-bottom: 0.5em; + font-size: 1.1em; + + .required { + color: crimson; + font-weight: bold; + } + } + + .resetUser { + margin-top: 0.5em; + } + + .options { + display: flex; + flex-flow: row wrap; + gap: 0.5em; + + .option { + padding: 2px 6px 2px 2px; + font-size: 0.9em; + border-radius: calc(10px + 0.9em); + background: transparent; + border: 1px solid $ung-dark-grey; + margin: 1px; + color: $ung-dark-grey; + text-transform: uppercase; + + & > svg { + width: 20px; + height: 20px; + } + + &.selected { + border-color: $ung-light-blue; + border-width: 2px; + margin: 0; + color: $ung-light-blue; + } + } + } + } + } + } +} diff --git a/src/components/UI/ModalForm.tsx b/src/components/UI/ModalForm.tsx new file mode 100644 index 0000000..7aba176 --- /dev/null +++ b/src/components/UI/ModalForm.tsx @@ -0,0 +1,215 @@ +import { PropsWithoutRef, ReactNode, useEffect, useState } from 'react'; +import { User } from '@/api/users/user.interface'; +import { useAppTranslation } from '@/lib/i18n'; +import { IconAdd, IconClose } from 'obra-icons-react'; +import { UserCard } from '../users/UserCard'; +import UserSelector from '../users/UserSelector'; +import Input from './Input'; +import Button from './Button'; +import styles from './ModalForm.module.scss'; + +interface DataModalType { + date: Date; + user: User; + string: string; + stringList: string[]; +} + +// These options are already of type [] +type OptionsFieldsRequired = 'stringList'; +// These options must NOT be of type [] +type OptionsFieldsPossible = 'string'; + +interface DataModalEntryBase { + type: T; + defaultValue?: DataModalType[T]; + /** When using options, you can choose to add label. The label must be provided as first element of the tuple */ + options: DataModalType[T] | [DataModalType[T], DataModalType[T]]; + label: ReactNode; + required?: boolean; +} + +type DataModalEntry = T extends OptionsFieldsRequired + ? DataModalEntryBase + : T extends OptionsFieldsPossible + ? Omit, 'options'> & { options?: DataModalEntryBase['options'][] } + : Omit, 'options'>; + +type DataModalKeys = { [key: string]: keyof DataModalType }; + +export type DataModalSchema = { + [S in keyof T]: DataModalEntry; +}; + +type ModalStates = { + [K in keyof Schema]: DataModalType[Schema[K]]; +}; + +export type WindowOptions = { + title: string; + submitText: ReactNode; +}; + +export type ModalCallbackType = { [K in keyof Schema]: DataModalType[Schema[K]] }; + +type ModalFormProps = PropsWithoutRef<{ + fields: DataModalSchema; + window: WindowOptions; + onSubmit: (data: ModalCallbackType) => void; + onClose: () => void; +}>; + +export function ModalForm({ fields, window, onSubmit, onClose }: ModalFormProps) { + const { t } = useAppTranslation(); + const [isHidden, setIsHidden] = useState(true); + const [states, setStates] = useState>( + Object.fromEntries( + Object.entries(fields).map(([key, field]) => { + if (field.type === 'stringList') return [key, field.defaultValue || []]; + if (field.type === 'date') return [key, field.defaultValue ?? new Date()]; + if (field.type === 'string') return [key, field.defaultValue ?? '']; + return [key, undefined]; + }), + ), + ); + + // Fade in effect + useEffect(() => { + requestAnimationFrame(() => setIsHidden(false)); + }, [fields]); + + const handleClose = () => { + setIsHidden(true); + setTimeout(onClose, 200); + }; + + const handleSubmit = () => { + onSubmit(states); + handleClose(); + }; + + const canValidate = () => + Object.entries(fields).every( + ([key, field]) => + !field.required || (Array.isArray(states[key]) ? (states[key] as string[]).length > 0 : states[key]), + ); + + return ( + <> +
c).join(' ')} + onClick={handleClose}>
+
c).join(' ')}> +
+ {window.title} +
+ +
+
+
+
+ {Object.entries(states).map(([key, state]) => { + let variant: ReactNode | null; + switch (fields[key].type) { + case 'string': + variant = + 'options' in fields[key] ? ( +
+ {fields[key].options!.map((option) => { + const optionLabel = Array.isArray(option) ? option[0] : option; + const optionValue = Array.isArray(option) ? option[1] : option; + return ( + + ); + })} +
+ ) : ( + setStates({ ...states, [key]: value })} /> + ); + break; + case 'date': + variant = ( + setStates({ ...states, [key]: new Date(value) })} + /> + ); + break; + case 'user': + variant = state ? ( + <> + + + + ) : ( + setStates({ ...states, [key]: user })} /> + ); + break; + case 'stringList': + variant = ( +
+ {fields[key].options.map((option) => { + const optionLabel = Array.isArray(option) ? option[0] : option; + const optionValue = Array.isArray(option) ? option[1] : option; + return ( + + ); + })} +
+ ); + break; + } + return ( +
+
+ {fields[key].label} + {fields[key].required ? * : ''} +
+ {variant} +
+ ); + })} +
+ +
+
+ + ); +} diff --git a/src/components/UI/VerticalSortDnd.tsx b/src/components/UI/VerticalSortDnd.tsx new file mode 100644 index 0000000..bf73fef --- /dev/null +++ b/src/components/UI/VerticalSortDnd.tsx @@ -0,0 +1,120 @@ +/* eslint-disable import/named */ +import { + ComponentType, + Dispatch, + PointerEvent, + PropsWithChildren, + PropsWithoutRef, + PropsWithRef, + SetStateAction, + useState, +} from 'react'; +import { + closestCenter, + DndContext, + DragEndEvent, + DragOverlay, + DragStartEvent, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { restrictToParentElement, restrictToVerticalAxis } from '@dnd-kit/modifiers'; +import { CSS } from '@dnd-kit/utilities'; + +class ClickPreservingPointerSensor extends PointerSensor { + static activators = [ + { + eventName: 'onPointerDown' as const, + handler: ({ nativeEvent: event }: PointerEvent) => { + const target = event.target as HTMLElement; + return !( + target.closest('button') || + target.closest('input') || + target.closest('textarea') || + target.closest('select') + ); + }, + }, + ]; +} + +function SortableItem(props: PropsWithChildren<{ id: string }>) { + const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: props.id }); + + if (transform) { + transform.scaleX = 1; + transform.scaleY = 1; + } + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+ {props.children} +
+ ); +} + +/** + * Allows to use drag and drop for element sorting (vertical layout) + * For more information about this component, see documentation here : https://docs.dndkit.com + */ +export const VerticalSortDnd = ({ + disabled, + items, + setItems, + inflater: Inflater, + onItemMoved = () => {}, +}: PropsWithoutRef<{ + disabled?: boolean; + inflater: ComponentType>; + items: T[]; + setItems: Dispatch>; + onItemMoved?: (id: string, newIndex: number, oldIndex: number) => void; +}>) => { + const sensors = useSensors(useSensor(ClickPreservingPointerSensor)); + const [activeId, setActiveId] = useState(null); + + const handleDragStart = (veent: DragStartEvent) => { + setActiveId(veent.active.id as string); + }; + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (active.id !== over?.id) { + setItems((items) => { + const oldIndex = items.findIndex((item) => item.id === active.id); + const newIndex = items.findIndex((item) => item.id === over?.id); + onItemMoved(active.id as string, newIndex, oldIndex); + return arrayMove(items, oldIndex, newIndex); // keep arrayMove call for animation purpose, the list may also be updated by api calls later on + }); + } + setActiveId(null); + }; + + return ( + + + i.id === activeId)!} /> + + + {items.map((item) => ( + + + + ))} + + + ); +}; diff --git a/src/components/assos/AssoRole.module.scss b/src/components/assos/AssoRole.module.scss new file mode 100644 index 0000000..d205d25 --- /dev/null +++ b/src/components/assos/AssoRole.module.scss @@ -0,0 +1,77 @@ +@import '@/variables'; + +.roleRoot { + position: relative; + + .actionRow { + display: flex; + flex-flow: row wrap; + gap: 0.5ch; + + :nth-child(1) { + flex: 2; + } + } + + .crown { + display: inline-block; + background-color: gold; + border-radius: 50%; + padding: 5px; + position: absolute; + left: -31px; + top: -0.05em; + width: 15px; + height: 15px; + box-sizing: content-box; + + svg { + display: block; + width: 100%; + height: 100%; + } + } +} + +.members { + display: flex; + flex-flow: row wrap; + gap: 2ch; + margin-top: 1ch; + + .oldMember { + opacity: 0.55; + transition: opacity 150ms ease-in-out; + + &:hover { + opacity: 1; + } + } + + .pictureContainer { + display: flex; + flex-flow: row nowrap; + gap: 1ch; + align-items: center; + + img { + width: 3.125ch; + height: 3.125ch; + border-radius: 50%; + object-fit: cover; + display: flex; + text-align: center; + align-items: center; + justify-content: center; + overflow: hidden; + flex-shrink: 0; + font-size: 1.6em; + background-color: rgba($color: $ung-light-grey, $alpha: 0.2); + } + + .temporal { + font-size: 0.8em; + color: $ung-dark-grey; + } + } +} diff --git a/src/components/assos/AssoRole.tsx b/src/components/assos/AssoRole.tsx new file mode 100644 index 0000000..1179a9d --- /dev/null +++ b/src/components/assos/AssoRole.tsx @@ -0,0 +1,141 @@ +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 Button from '../UI/Button'; +import Input from '../UI/Input'; +import Link from '../UI/Link'; +import { IconCheck, IconCrown, IconDelete, IconEdit, IconUserAdd, IconUserCross } from 'obra-icons-react'; + +export function AssoRole({ + role, + editing = false, + canEdit, + hasPermission, + hasMembersPermission, + displayOldMembers, + deleteAssoRole, + updateAssoRole, + createAssoMember, + deleteAssoMember, + updateAssoMember, + setCurrentEditingRole, +}: PropsWithoutRef<{ + role: Role; + editing?: boolean; + canEdit: boolean; + hasPermission: boolean; + hasMembersPermission: boolean; + displayOldMembers: boolean; + deleteAssoRole: (id: string) => void; + updateAssoRole: (id: string, data: Partial<{ name: string; position: number }>) => void; + createAssoMember: (roleId: string) => void; + deleteAssoMember: (id: string) => void; + updateAssoMember: (member: Member, fromRoleId: string) => void; + setCurrentEditingRole: (id: string | null) => void; +}>) { + const [currentEditingRoleValue, setCurrentEditingRoleValue] = useState(role.name); + const { t } = useAppTranslation(); + + const userSorter = (a: Member, b: Member) => { + const aSign = Math.sign(a.endAt.getTime() - Date.now()); + const oldComparison = Math.sign(b.endAt.getTime() - Date.now()) - aSign; + return ( + oldComparison || (aSign > 0 ? a.startAt.getTime() - b.startAt.getTime() : b.endAt.getTime() - a.endAt.getTime()) + ); + }; + + return ( + <> +

+ {role.isPresident ? ( +
+ +
+ ) : ( + '' + )} +
+ {editing && canEdit ? ( + + ) : ( +
{role.name}
+ )} + {canEdit && ( + <> + + + + + )} +
+

+
+ {role.members.sort(userSorter).map((member) => { + const isOld = member.endAt < new Date(); + return ( + (!isOld || displayOldMembers || canEdit) && ( + +
+ {member?.firstName.charAt(0) +
+
+ {member.firstName} {member.lastName} +
+
+ {t(isOld ? 'assos:member.old.from' : 'assos:member.since')} + {member.startAt.toLocaleString(undefined, { + year: 'numeric', + month: 'long', + })} + {isOld && ( + <> + {t('assos:member.old.to')} + {member.endAt.toLocaleString(undefined, { + year: 'numeric', + month: 'long', + })} + + )} +
+
+ {!isOld && canEdit && ( + <> + + + + )} +
+ + ) + ); + })} +
+ + ); +} diff --git a/src/components/homeWidgets/DailyTimetableWidget.tsx b/src/components/homeWidgets/DailyTimetableWidget.tsx index d4d86b2..0afc0c2 100644 --- a/src/components/homeWidgets/DailyTimetableWidget.tsx +++ b/src/components/homeWidgets/DailyTimetableWidget.tsx @@ -5,7 +5,7 @@ import { GetDailyTimetableResponseDto, TimetableEvent } from '@/api/users/getDai 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'; @@ -83,13 +83,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/toplevel/GoTo.tsx b/src/components/toplevel/GoTo.tsx index 240eb61..11d1f68 100644 --- a/src/components/toplevel/GoTo.tsx +++ b/src/components/toplevel/GoTo.tsx @@ -28,6 +28,11 @@ const searchEntries: SearchEntry[] = [ url: '/ues', keywordTranslationKeys: ['goTo:ues.normal.keywords'], }, + { + name: 'goTo:assos.normal', + url: '/assos', + keywordTranslationKeys: ['goTo:assos.normal.keywords'], + }, ]; export default function GoTo() { @@ -103,7 +108,8 @@ export default function GoTo() { + className={`${styles.result} ${i === selectedResultIndex ? styles.selected : ''}`} + noStyle> {t(translatedSearchEntries[entryIndex].name)} ))} diff --git a/src/components/users/UserCard.module.scss b/src/components/users/UserCard.module.scss new file mode 100644 index 0000000..3f765f3 --- /dev/null +++ b/src/components/users/UserCard.module.scss @@ -0,0 +1,50 @@ +@import '@/variables.scss'; + +.user { + background: white; + border-radius: 5px; + padding: 1ch; + display: flex; + flex-flow: row nowrap; + align-items: center; + gap: 1ch; + user-select: none; + + .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; + + img { + position: absolute; + object-fit: cover; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + } + + .info { + display: flex; + flex-flow: column nowrap; + gap: 0.2ch; + color: $ung-dark-grey; + + :nth-child(2) { + font-style: italic; + opacity: 0.8; + } + } +} diff --git a/src/components/users/UserCard.tsx b/src/components/users/UserCard.tsx new file mode 100644 index 0000000..77b2f5a --- /dev/null +++ b/src/components/users/UserCard.tsx @@ -0,0 +1,38 @@ +import { User } from '@/api/users/user.interface'; +import { useAppTranslation } from '@/lib/i18n'; +import styles from './UserCard.module.scss'; + +export function UserCard({ user, onSelect }: { user: User; onSelect?: (user: User) => void }) { + const { t } = useAppTranslation(); + return ( +
onSelect?.(user)}> +
+ {user?.avatar ? ( + {`${user.firstName} + ) : ( + user?.firstName.charAt(0) || '?' + )} +
+
+
+ {user?.firstName || user?.lastName ? ( + <> + {user?.firstName} {user?.lastName} + + ) : ( + t('users:selector.ui.noName') + )} +
+
+ {user?.branch || user?.semester ? ( + <> + {user?.branch} {user?.semester} + + ) : ( + t('users:selector.ui.noCursus') + )} +
+
+
+ ); +} diff --git a/src/components/users/UserSelector.module.scss b/src/components/users/UserSelector.module.scss new file mode 100644 index 0000000..b7cc4d3 --- /dev/null +++ b/src/components/users/UserSelector.module.scss @@ -0,0 +1,27 @@ +@import '@/variables.scss'; + +.userSelector { + display: flex; + flex-flow: column nowrap; + gap: 1ch; + + .resultPool { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 1ch; + + .noResult { + display: block; + position: absolute; + text-align: center; + font-style: italic; + width: 100%; + top: 50%; + transform: translateY(-50%); + } + + & > :not(.noResult) { + cursor: pointer; + } + } +} diff --git a/src/components/users/UserSelector.tsx b/src/components/users/UserSelector.tsx new file mode 100644 index 0000000..4dbf0b6 --- /dev/null +++ b/src/components/users/UserSelector.tsx @@ -0,0 +1,60 @@ +import { PropsWithoutRef, useEffect, useState } from 'react'; +import { useAppTranslation } from '@/lib/i18n'; +import { useUsers } from '@/api/users/searchUsers.hook'; +import { User } from '@/api/users/user.interface'; +import Input from '../UI/Input'; +import styles from './UserSelector.module.scss'; +import { UserCard } from './UserCard'; + +export default function UserSelector({ + updateInterval = 1000, + onSelect, +}: PropsWithoutRef<{ updateInterval?: number; onSelect?: (user: User) => void }>) { + const { t } = useAppTranslation(); + + const { items, updateFilters } = useUsers(); + + const [searchString, setSearchString] = useState(''); + const [timeoutId, setTimeoutId] = useState(-1); + const [lastUpdate, setLastUpdate] = useState(0); + + useEffect(() => { + if (Date.now() - lastUpdate >= updateInterval) { + updateFilters({ q: searchString }); + setLastUpdate(Date.now()); + } else { + if (timeoutId >= 0) clearTimeout(timeoutId); + setTimeoutId( + window.setTimeout( + () => { + updateFilters({ q: searchString }); + setLastUpdate(Date.now()); + setTimeoutId(-1); + }, + updateInterval - (Date.now() - lastUpdate), + ), + ); + } + }, [searchString]); + + const select = (user: User) => { + setSearchString(''); + onSelect?.(user); + }; + + return ( +
+ setSearchString(value)} + placeholder={t('users:selector.ui.placeholder')} + /> +
+ {!items.length &&
{t('users:selector.ui.noResult')}
} + {items.map( + (user: User | null) => user && select(user)} />, + )} +
+
+ ); +} diff --git a/src/icons/Add.tsx b/src/icons/Add.tsx new file mode 100644 index 0000000..3d5e07b --- /dev/null +++ b/src/icons/Add.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export default function Add() { + return ( + + + + ); +} diff --git a/src/icons/Close.tsx b/src/icons/Close.tsx new file mode 100644 index 0000000..2cb725e --- /dev/null +++ b/src/icons/Close.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export default function Close() { + return ( + + + + ); +} diff --git a/src/icons/Confirm.tsx b/src/icons/Confirm.tsx new file mode 100644 index 0000000..70bb7c9 --- /dev/null +++ b/src/icons/Confirm.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export default function Confirm() { + return ( + + + + ); +} diff --git a/src/icons/Crown.tsx b/src/icons/Crown.tsx new file mode 100644 index 0000000..97d16b3 --- /dev/null +++ b/src/icons/Crown.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +export default function Crown() { + return ( + + + + + ); +} diff --git a/src/icons/Edit.tsx b/src/icons/Edit.tsx new file mode 100644 index 0000000..0e67de4 --- /dev/null +++ b/src/icons/Edit.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export default function Edit() { + return ( + + + + ); +} diff --git a/src/icons/EyeOff.tsx b/src/icons/EyeOff.tsx new file mode 100644 index 0000000..a1a2890 --- /dev/null +++ b/src/icons/EyeOff.tsx @@ -0,0 +1,10 @@ +import React from 'react'; + +export default function EyeOff() { + return ( + + + + + ); +} diff --git a/src/icons/EyeOn.tsx b/src/icons/EyeOn.tsx new file mode 100644 index 0000000..8090462 --- /dev/null +++ b/src/icons/EyeOn.tsx @@ -0,0 +1,10 @@ +import React from 'react'; + +export default function EyeOn() { + return ( + + + + + ); +} diff --git a/src/icons/LinkExternal.tsx b/src/icons/LinkExternal.tsx new file mode 100644 index 0000000..aa3b878 --- /dev/null +++ b/src/icons/LinkExternal.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export default function LinkExternal() { + return ( + + + + ); +} diff --git a/src/icons/Mail.tsx b/src/icons/Mail.tsx new file mode 100644 index 0000000..e143976 --- /dev/null +++ b/src/icons/Mail.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export default function Mail() { + return ( + + + + ); +} diff --git a/src/icons/Phone.tsx b/src/icons/Phone.tsx new file mode 100644 index 0000000..db24ea7 --- /dev/null +++ b/src/icons/Phone.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export default function Phone() { + return ( + + + + ); +} diff --git a/src/icons/Trash.tsx b/src/icons/Trash.tsx index 7f360d9..21815ed 100644 --- a/src/icons/Trash.tsx +++ b/src/icons/Trash.tsx @@ -1,6 +1,12 @@ export default function Trash({ className }: { className?: string }) { return ( - + diff --git a/src/icons/UserAdd.tsx b/src/icons/UserAdd.tsx new file mode 100644 index 0000000..349f312 --- /dev/null +++ b/src/icons/UserAdd.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export default function UserAdd() { + return ( + + {' '} + + ); +} diff --git a/src/icons/UserRemove.tsx b/src/icons/UserRemove.tsx new file mode 100644 index 0000000..3211946 --- /dev/null +++ b/src/icons/UserRemove.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export default function UserRemove() { + return ( + + + + ); +} diff --git a/src/icons/index.ts b/src/icons/index.ts index 389adaf..46e8e8c 100644 --- a/src/icons/index.ts +++ b/src/icons/index.ts @@ -22,30 +22,54 @@ import CircleWarning from './CircleWarning'; import Clock from './Clock'; import Rotate from './Rotate'; import Copy from './Copy'; +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, + Confirm, + Crown, + Edit, + 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, }; 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, diff --git a/src/utils/environment.ts b/src/utils/environment.ts index 31f47bd..e73f762 100644 --- a/src/utils/environment.ts +++ b/src/utils/environment.ts @@ -2,7 +2,7 @@ export const nodeEnv = () => process.env.NODE_ENV; export const isDevEnv = () => process.env.NODE_ENV === 'development'; export const apiUrl = process.env.NEXT_PUBLIC_API_URL || ''; -export const apiVersion = process.env.NEXT_PUBLIC_API_VERSION || 'v0'; +export const apiVersion = Number(process.env.NEXT_PUBLIC_API_VERSION || 0); export const apiTimeout = Number(process.env.NEXT_PUBLIC_API_REQUEST_TIMEOUT || 0); export const isServerSide = () => typeof window === 'undefined'; export const isClientSide = () => typeof window !== 'undefined';