mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-09-09 16:53:33 +02:00
wip
This commit is contained in:
@@ -42,6 +42,14 @@
|
|||||||
"@roadmapsh/editor": "workspace:*",
|
"@roadmapsh/editor": "workspace:*",
|
||||||
"@tailwindcss/vite": "^4.1.6",
|
"@tailwindcss/vite": "^4.1.6",
|
||||||
"@tanstack/react-query": "^5.76.1",
|
"@tanstack/react-query": "^5.76.1",
|
||||||
|
"@tiptap/core": "^2.12.0",
|
||||||
|
"@tiptap/extension-document": "^2.12.0",
|
||||||
|
"@tiptap/extension-paragraph": "^2.12.0",
|
||||||
|
"@tiptap/extension-placeholder": "^2.12.0",
|
||||||
|
"@tiptap/extension-text": "^2.12.0",
|
||||||
|
"@tiptap/pm": "^2.12.0",
|
||||||
|
"@tiptap/react": "^2.12.0",
|
||||||
|
"@tiptap/suggestion": "^2.12.0",
|
||||||
"@types/react": "^19.1.4",
|
"@types/react": "^19.1.4",
|
||||||
"@types/react-dom": "^19.1.5",
|
"@types/react-dom": "^19.1.5",
|
||||||
"astro": "^5.7.13",
|
"astro": "^5.7.13",
|
||||||
@@ -81,6 +89,7 @@
|
|||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
"tailwind-merge": "^3.3.0",
|
"tailwind-merge": "^3.3.0",
|
||||||
"tailwindcss": "^4.1.6",
|
"tailwindcss": "^4.1.6",
|
||||||
|
"tippy.js": "^6.3.7",
|
||||||
"tiptap-markdown": "^0.8.10",
|
"tiptap-markdown": "^0.8.10",
|
||||||
"turndown": "^7.2.0",
|
"turndown": "^7.2.0",
|
||||||
"unified": "^11.0.5",
|
"unified": "^11.0.5",
|
||||||
|
138
pnpm-lock.yaml
generated
138
pnpm-lock.yaml
generated
@@ -41,6 +41,30 @@ importers:
|
|||||||
'@tanstack/react-query':
|
'@tanstack/react-query':
|
||||||
specifier: ^5.76.1
|
specifier: ^5.76.1
|
||||||
version: 5.76.1(react@19.1.0)
|
version: 5.76.1(react@19.1.0)
|
||||||
|
'@tiptap/core':
|
||||||
|
specifier: ^2.12.0
|
||||||
|
version: 2.12.0(@tiptap/pm@2.12.0)
|
||||||
|
'@tiptap/extension-document':
|
||||||
|
specifier: ^2.12.0
|
||||||
|
version: 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))
|
||||||
|
'@tiptap/extension-paragraph':
|
||||||
|
specifier: ^2.12.0
|
||||||
|
version: 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))
|
||||||
|
'@tiptap/extension-placeholder':
|
||||||
|
specifier: ^2.12.0
|
||||||
|
version: 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)
|
||||||
|
'@tiptap/extension-text':
|
||||||
|
specifier: ^2.12.0
|
||||||
|
version: 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))
|
||||||
|
'@tiptap/pm':
|
||||||
|
specifier: ^2.12.0
|
||||||
|
version: 2.12.0
|
||||||
|
'@tiptap/react':
|
||||||
|
specifier: ^2.12.0
|
||||||
|
version: 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
|
'@tiptap/suggestion':
|
||||||
|
specifier: ^2.12.0
|
||||||
|
version: 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^19.1.4
|
specifier: ^19.1.4
|
||||||
version: 19.1.4
|
version: 19.1.4
|
||||||
@@ -158,6 +182,9 @@ importers:
|
|||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.1.6
|
specifier: ^4.1.6
|
||||||
version: 4.1.6
|
version: 4.1.6
|
||||||
|
tippy.js:
|
||||||
|
specifier: ^6.3.7
|
||||||
|
version: 6.3.7
|
||||||
tiptap-markdown:
|
tiptap-markdown:
|
||||||
specifier: ^0.8.10
|
specifier: ^0.8.10
|
||||||
version: 0.8.10(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))
|
version: 0.8.10(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))
|
||||||
@@ -983,6 +1010,9 @@ packages:
|
|||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
'@popperjs/core@2.11.8':
|
||||||
|
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
|
||||||
|
|
||||||
'@remirror/core-constants@3.0.0':
|
'@remirror/core-constants@3.0.0':
|
||||||
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
|
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
|
||||||
|
|
||||||
@@ -1396,9 +1426,56 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@tiptap/pm': ^2.7.0
|
'@tiptap/pm': ^2.7.0
|
||||||
|
|
||||||
|
'@tiptap/extension-bubble-menu@2.12.0':
|
||||||
|
resolution: {integrity: sha512-DYijoE0igV0Oi+ZppFsp2UrQsM/4HZtmmpD78BJM9zfCbd1YvAUIxmzmXr8uqU18OHd1uQy+/zvuNoUNYyw67g==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': ^2.7.0
|
||||||
|
'@tiptap/pm': ^2.7.0
|
||||||
|
|
||||||
|
'@tiptap/extension-document@2.12.0':
|
||||||
|
resolution: {integrity: sha512-sA1Q+mxDIv0Y3qQTBkYGwknNbDcGFiJ/fyAFholXpqbrcRx3GavwR/o0chBdsJZlFht0x7AWGwUYWvIo7wYilA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': ^2.7.0
|
||||||
|
|
||||||
|
'@tiptap/extension-floating-menu@2.12.0':
|
||||||
|
resolution: {integrity: sha512-BYpyZx/56KCDksWuJJbhki/uNgt9sACuSSZFH5AN1yS1ISD+EzIxqf6Pzzv8QCoNJ+KcRNVaZsOlOFaJGoyzag==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': ^2.7.0
|
||||||
|
'@tiptap/pm': ^2.7.0
|
||||||
|
|
||||||
|
'@tiptap/extension-paragraph@2.12.0':
|
||||||
|
resolution: {integrity: sha512-QNK5cgewCunWFxpLlbvvoO1rrLgEtNKxiY79fctP9toV+e59R+1i1Q9lXC1O5mOfDgVxCb6uFDMsqmKhFjpPog==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': ^2.7.0
|
||||||
|
|
||||||
|
'@tiptap/extension-placeholder@2.12.0':
|
||||||
|
resolution: {integrity: sha512-K7irDox4P+NLAMjVrJeG72f0sulsCRYpx1Cy4gxKCdi1LTivj5VkXa6MXmi42KTCwBu3pWajBctYIOAES1FTAA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': ^2.7.0
|
||||||
|
'@tiptap/pm': ^2.7.0
|
||||||
|
|
||||||
|
'@tiptap/extension-text@2.12.0':
|
||||||
|
resolution: {integrity: sha512-0ytN9V1tZYTXdiYDQg4FB2SQ56JAJC9r/65snefb9ztl+gZzDrIvih7CflHs1ic9PgyjexfMLeH+VzuMccNyZw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': ^2.7.0
|
||||||
|
|
||||||
'@tiptap/pm@2.12.0':
|
'@tiptap/pm@2.12.0':
|
||||||
resolution: {integrity: sha512-TNzVwpeNzFfHAcYTOKqX9iU4fRxliyoZrCnERR+RRzeg7gWrXrCLubQt1WEx0sojMAfznshSL3M5HGsYjEbYwA==}
|
resolution: {integrity: sha512-TNzVwpeNzFfHAcYTOKqX9iU4fRxliyoZrCnERR+RRzeg7gWrXrCLubQt1WEx0sojMAfznshSL3M5HGsYjEbYwA==}
|
||||||
|
|
||||||
|
'@tiptap/react@2.12.0':
|
||||||
|
resolution: {integrity: sha512-D+PR+4kJO9h8AB/7XyQ/Anw8tqeS2ecv5QemBOCHi9JlMAjytauUrj6IfFBO9RbsCowlBjW5GnSpFhzpk2Gghg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': ^2.7.0
|
||||||
|
'@tiptap/pm': ^2.7.0
|
||||||
|
react: ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
|
'@tiptap/suggestion@2.12.0':
|
||||||
|
resolution: {integrity: sha512-bsXLoZbjUo1oOF1Z+XSfoGzbcnrTcYtJdfylM/FerMLU9T12dhsM/Ri2SKLX4IR5D0HJ07FcsEHCrGEy8Y5y0A==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': ^2.7.0
|
||||||
|
'@tiptap/pm': ^2.7.0
|
||||||
|
|
||||||
'@tybys/wasm-util@0.9.0':
|
'@tybys/wasm-util@0.9.0':
|
||||||
resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==}
|
resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==}
|
||||||
|
|
||||||
@@ -1527,6 +1604,9 @@ packages:
|
|||||||
'@types/unist@3.0.3':
|
'@types/unist@3.0.3':
|
||||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||||
|
|
||||||
|
'@types/use-sync-external-store@0.0.6':
|
||||||
|
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
|
||||||
|
|
||||||
'@ungap/structured-clone@1.3.0':
|
'@ungap/structured-clone@1.3.0':
|
||||||
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
||||||
|
|
||||||
@@ -2795,6 +2875,7 @@ packages:
|
|||||||
node-domexception@1.0.0:
|
node-domexception@1.0.0:
|
||||||
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
||||||
engines: {node: '>=10.5.0'}
|
engines: {node: '>=10.5.0'}
|
||||||
|
deprecated: Use your platform's native DOMException instead
|
||||||
|
|
||||||
node-fetch-native@1.6.6:
|
node-fetch-native@1.6.6:
|
||||||
resolution: {integrity: sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ==}
|
resolution: {integrity: sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ==}
|
||||||
@@ -3482,6 +3563,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==}
|
resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
|
tippy.js@6.3.7:
|
||||||
|
resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==}
|
||||||
|
|
||||||
tiptap-markdown@0.8.10:
|
tiptap-markdown@0.8.10:
|
||||||
resolution: {integrity: sha512-iDVkR2BjAqkTDtFX0h94yVvE2AihCXlF0Q7RIXSJPRSR5I0PA1TMuAg6FHFpmqTn4tPxJ0by0CK7PUMlnFLGEQ==}
|
resolution: {integrity: sha512-iDVkR2BjAqkTDtFX0h94yVvE2AihCXlF0Q7RIXSJPRSR5I0PA1TMuAg6FHFpmqTn4tPxJ0by0CK7PUMlnFLGEQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -4542,6 +4626,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
playwright: 1.52.0
|
playwright: 1.52.0
|
||||||
|
|
||||||
|
'@popperjs/core@2.11.8': {}
|
||||||
|
|
||||||
'@remirror/core-constants@3.0.0': {}
|
'@remirror/core-constants@3.0.0': {}
|
||||||
|
|
||||||
'@resvg/resvg-js-android-arm-eabi@2.6.2':
|
'@resvg/resvg-js-android-arm-eabi@2.6.2':
|
||||||
@@ -4861,6 +4947,35 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/pm': 2.12.0
|
'@tiptap/pm': 2.12.0
|
||||||
|
|
||||||
|
'@tiptap/extension-bubble-menu@2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 2.12.0(@tiptap/pm@2.12.0)
|
||||||
|
'@tiptap/pm': 2.12.0
|
||||||
|
tippy.js: 6.3.7
|
||||||
|
|
||||||
|
'@tiptap/extension-document@2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 2.12.0(@tiptap/pm@2.12.0)
|
||||||
|
|
||||||
|
'@tiptap/extension-floating-menu@2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 2.12.0(@tiptap/pm@2.12.0)
|
||||||
|
'@tiptap/pm': 2.12.0
|
||||||
|
tippy.js: 6.3.7
|
||||||
|
|
||||||
|
'@tiptap/extension-paragraph@2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 2.12.0(@tiptap/pm@2.12.0)
|
||||||
|
|
||||||
|
'@tiptap/extension-placeholder@2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 2.12.0(@tiptap/pm@2.12.0)
|
||||||
|
'@tiptap/pm': 2.12.0
|
||||||
|
|
||||||
|
'@tiptap/extension-text@2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 2.12.0(@tiptap/pm@2.12.0)
|
||||||
|
|
||||||
'@tiptap/pm@2.12.0':
|
'@tiptap/pm@2.12.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
prosemirror-changeset: 2.3.0
|
prosemirror-changeset: 2.3.0
|
||||||
@@ -4882,6 +4997,23 @@ snapshots:
|
|||||||
prosemirror-transform: 1.10.4
|
prosemirror-transform: 1.10.4
|
||||||
prosemirror-view: 1.39.2
|
prosemirror-view: 1.39.2
|
||||||
|
|
||||||
|
'@tiptap/react@2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 2.12.0(@tiptap/pm@2.12.0)
|
||||||
|
'@tiptap/extension-bubble-menu': 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)
|
||||||
|
'@tiptap/extension-floating-menu': 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)
|
||||||
|
'@tiptap/pm': 2.12.0
|
||||||
|
'@types/use-sync-external-store': 0.0.6
|
||||||
|
fast-deep-equal: 3.1.3
|
||||||
|
react: 19.1.0
|
||||||
|
react-dom: 19.1.0(react@19.1.0)
|
||||||
|
use-sync-external-store: 1.5.0(react@19.1.0)
|
||||||
|
|
||||||
|
'@tiptap/suggestion@2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 2.12.0(@tiptap/pm@2.12.0)
|
||||||
|
'@tiptap/pm': 2.12.0
|
||||||
|
|
||||||
'@tybys/wasm-util@0.9.0':
|
'@tybys/wasm-util@0.9.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
@@ -5028,6 +5160,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/unist@3.0.3': {}
|
'@types/unist@3.0.3': {}
|
||||||
|
|
||||||
|
'@types/use-sync-external-store@0.0.6': {}
|
||||||
|
|
||||||
'@ungap/structured-clone@1.3.0': {}
|
'@ungap/structured-clone@1.3.0': {}
|
||||||
|
|
||||||
'@vitejs/plugin-react@4.4.1(vite@6.3.5(@types/node@22.15.17)(jiti@2.4.2)(lightningcss@1.29.2)(tsx@4.19.4))':
|
'@vitejs/plugin-react@4.4.1(vite@6.3.5(@types/node@22.15.17)(jiti@2.4.2)(lightningcss@1.29.2)(tsx@4.19.4))':
|
||||||
@@ -7300,6 +7434,10 @@ snapshots:
|
|||||||
fdir: 6.4.4(picomatch@4.0.2)
|
fdir: 6.4.4(picomatch@4.0.2)
|
||||||
picomatch: 4.0.2
|
picomatch: 4.0.2
|
||||||
|
|
||||||
|
tippy.js@6.3.7:
|
||||||
|
dependencies:
|
||||||
|
'@popperjs/core': 2.11.8
|
||||||
|
|
||||||
tiptap-markdown@0.8.10(@tiptap/core@2.12.0(@tiptap/pm@2.12.0)):
|
tiptap-markdown@0.8.10(@tiptap/core@2.12.0(@tiptap/pm@2.12.0)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 2.12.0(@tiptap/pm@2.12.0)
|
'@tiptap/core': 2.12.0(@tiptap/pm@2.12.0)
|
||||||
|
25
src/components/ChatEditor/ChatEditor.css
Normal file
25
src/components/ChatEditor/ChatEditor.css
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
.chat-editor .tiptap p.is-editor-empty:first-child::before {
|
||||||
|
color: #adb5bd;
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-editor .tiptap p.is-empty::before {
|
||||||
|
color: #adb5bd;
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-editor .tiptap [data-type='variable'] {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: #f0f5ff;
|
||||||
|
color: #2c5df1;
|
||||||
|
}
|
59
src/components/ChatEditor/ChatEditor.tsx
Normal file
59
src/components/ChatEditor/ChatEditor.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import './ChatEditor.css';
|
||||||
|
|
||||||
|
import { EditorContent, useEditor } from '@tiptap/react';
|
||||||
|
import DocumentExtension from '@tiptap/extension-document';
|
||||||
|
import ParagraphExtension from '@tiptap/extension-paragraph';
|
||||||
|
import TextExtension from '@tiptap/extension-text';
|
||||||
|
import Placeholder from '@tiptap/extension-placeholder';
|
||||||
|
import { VariableExtension } from './VariableExtension/VariableExtension';
|
||||||
|
import { variableSuggestion } from './VariableExtension/VariableSuggestion';
|
||||||
|
|
||||||
|
const extensions = [
|
||||||
|
DocumentExtension,
|
||||||
|
ParagraphExtension,
|
||||||
|
TextExtension,
|
||||||
|
Placeholder.configure({
|
||||||
|
placeholder: 'Ask AI anything about the roadmap...',
|
||||||
|
}),
|
||||||
|
VariableExtension.configure({
|
||||||
|
suggestion: variableSuggestion(),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const content = '<p></p>';
|
||||||
|
|
||||||
|
export function ChatEditor() {
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions,
|
||||||
|
content,
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class: 'focus:outline-none w-full p-2',
|
||||||
|
},
|
||||||
|
handleKeyDown(_, event) {
|
||||||
|
if (!editor) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter' && event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
editor.commands.insertContent('<p></p>');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-editor w-full">
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@@ -0,0 +1,311 @@
|
|||||||
|
import { mergeAttributes, Node } from '@tiptap/core';
|
||||||
|
import { type DOMOutputSpec, Node as ProseMirrorNode } from '@tiptap/pm/model';
|
||||||
|
import { PluginKey } from '@tiptap/pm/state';
|
||||||
|
import Suggestion, { type SuggestionOptions } from '@tiptap/suggestion';
|
||||||
|
|
||||||
|
// See `addAttributes` below
|
||||||
|
export interface VariableNodeAttrs {
|
||||||
|
/**
|
||||||
|
* The identifier for the selected item that was mentioned, stored as a `data-id`
|
||||||
|
* attribute.
|
||||||
|
*/
|
||||||
|
id: string | null;
|
||||||
|
/**
|
||||||
|
* The label to be rendered by the editor as the displayed text for this mentioned
|
||||||
|
* item, if provided. Stored as a `data-label` attribute. See `renderLabel`.
|
||||||
|
*/
|
||||||
|
label?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VariableOptions<
|
||||||
|
SuggestionItem = any,
|
||||||
|
Attrs extends Record<string, any> = VariableNodeAttrs,
|
||||||
|
> = {
|
||||||
|
/**
|
||||||
|
* The HTML attributes for a mention node.
|
||||||
|
* @default {}
|
||||||
|
* @example { class: 'foo' }
|
||||||
|
*/
|
||||||
|
HTMLAttributes: Record<string, any>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A function to render the label of a mention.
|
||||||
|
* @deprecated use renderText and renderHTML instead
|
||||||
|
* @param props The render props
|
||||||
|
* @returns The label
|
||||||
|
* @example ({ options, node }) => `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`
|
||||||
|
*/
|
||||||
|
renderLabel?: (props: {
|
||||||
|
options: VariableOptions<SuggestionItem, Attrs>;
|
||||||
|
node: ProseMirrorNode;
|
||||||
|
}) => string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A function to render the text of a mention.
|
||||||
|
* @param props The render props
|
||||||
|
* @returns The text
|
||||||
|
* @example ({ options, node }) => `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`
|
||||||
|
*/
|
||||||
|
renderText: (props: {
|
||||||
|
options: VariableOptions<SuggestionItem, Attrs>;
|
||||||
|
node: ProseMirrorNode;
|
||||||
|
}) => string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A function to render the HTML of a mention.
|
||||||
|
* @param props The render props
|
||||||
|
* @returns The HTML as a ProseMirror DOM Output Spec
|
||||||
|
* @example ({ options, node }) => ['span', { 'data-type': 'mention' }, `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`]
|
||||||
|
*/
|
||||||
|
renderHTML: (props: {
|
||||||
|
options: VariableOptions<SuggestionItem, Attrs>;
|
||||||
|
node: ProseMirrorNode;
|
||||||
|
}) => DOMOutputSpec;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to delete the trigger character with backspace.
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
deleteTriggerWithBackspace: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The suggestion options.
|
||||||
|
* @default {}
|
||||||
|
* @example { char: '@', pluginKey: MentionPluginKey, command: ({ editor, range, props }) => { ... } }
|
||||||
|
*/
|
||||||
|
suggestion: Omit<SuggestionOptions<SuggestionItem, Attrs>, 'editor'>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VariableType = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VariableStorage = {
|
||||||
|
variables: VariableType[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plugin key for the variable plugin.
|
||||||
|
* @default 'variable'
|
||||||
|
*/
|
||||||
|
export const VariablePluginKey = new PluginKey('variable');
|
||||||
|
|
||||||
|
export const VariableExtension = Node.create<VariableOptions, VariableStorage>({
|
||||||
|
name: 'variable',
|
||||||
|
|
||||||
|
priority: 101,
|
||||||
|
|
||||||
|
addStorage() {
|
||||||
|
return {
|
||||||
|
variables: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
HTMLAttributes: {},
|
||||||
|
renderText({ options, node }) {
|
||||||
|
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
|
||||||
|
},
|
||||||
|
deleteTriggerWithBackspace: false,
|
||||||
|
renderHTML({ options, node }) {
|
||||||
|
return [
|
||||||
|
'span',
|
||||||
|
mergeAttributes(this.HTMLAttributes, options.HTMLAttributes),
|
||||||
|
`${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
suggestion: {
|
||||||
|
char: '@',
|
||||||
|
pluginKey: VariablePluginKey,
|
||||||
|
command: ({ editor, range, props }) => {
|
||||||
|
// increase range.to by one when the next node is of type "text"
|
||||||
|
// and starts with a space character
|
||||||
|
const nodeAfter = editor.view.state.selection.$to.nodeAfter;
|
||||||
|
const overrideSpace = nodeAfter?.text?.startsWith(' ');
|
||||||
|
|
||||||
|
if (overrideSpace) {
|
||||||
|
range.to += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.insertContentAt(range, [
|
||||||
|
{
|
||||||
|
type: this.name,
|
||||||
|
attrs: props,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: ' ',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// get reference to `window` object from editor element, to support cross-frame JS usage
|
||||||
|
editor.view.dom.ownerDocument.defaultView
|
||||||
|
?.getSelection()
|
||||||
|
?.collapseToEnd();
|
||||||
|
},
|
||||||
|
allow: ({ state, range }) => {
|
||||||
|
const $from = state.doc.resolve(range.from);
|
||||||
|
const type = state.schema.nodes[this.name];
|
||||||
|
const allow = !!$from.parent.type.contentMatch.matchType(type);
|
||||||
|
|
||||||
|
return allow;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
group: 'inline',
|
||||||
|
|
||||||
|
inline: true,
|
||||||
|
|
||||||
|
selectable: false,
|
||||||
|
|
||||||
|
atom: true,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
id: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => element.getAttribute('data-id'),
|
||||||
|
renderHTML: (attributes) => {
|
||||||
|
if (!attributes.id) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'data-id': attributes.id,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
label: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => element.getAttribute('data-label'),
|
||||||
|
renderHTML: (attributes) => {
|
||||||
|
if (!attributes.label) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'data-label': attributes.label,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: `span[data-type="${this.name}"]`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ node, HTMLAttributes }) {
|
||||||
|
if (this.options.renderLabel !== undefined) {
|
||||||
|
console.warn(
|
||||||
|
'renderLabel is deprecated use renderText and renderHTML instead',
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
'span',
|
||||||
|
mergeAttributes(
|
||||||
|
{ 'data-type': this.name },
|
||||||
|
this.options.HTMLAttributes,
|
||||||
|
HTMLAttributes,
|
||||||
|
),
|
||||||
|
this.options.renderLabel({
|
||||||
|
options: this.options,
|
||||||
|
node,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const mergedOptions = { ...this.options };
|
||||||
|
|
||||||
|
mergedOptions.HTMLAttributes = mergeAttributes(
|
||||||
|
{ 'data-type': this.name },
|
||||||
|
this.options.HTMLAttributes,
|
||||||
|
HTMLAttributes,
|
||||||
|
);
|
||||||
|
const html = this.options.renderHTML({
|
||||||
|
options: mergedOptions,
|
||||||
|
node,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof html === 'string') {
|
||||||
|
return [
|
||||||
|
'span',
|
||||||
|
mergeAttributes(
|
||||||
|
{ 'data-type': this.name },
|
||||||
|
this.options.HTMLAttributes,
|
||||||
|
HTMLAttributes,
|
||||||
|
),
|
||||||
|
html,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
},
|
||||||
|
|
||||||
|
renderText({ node }) {
|
||||||
|
if (this.options.renderLabel !== undefined) {
|
||||||
|
console.warn(
|
||||||
|
'renderLabel is deprecated use renderText and renderHTML instead',
|
||||||
|
);
|
||||||
|
return this.options.renderLabel({
|
||||||
|
options: this.options,
|
||||||
|
node,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.options.renderText({
|
||||||
|
options: this.options,
|
||||||
|
node,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
addKeyboardShortcuts() {
|
||||||
|
return {
|
||||||
|
Backspace: () =>
|
||||||
|
this.editor.commands.command(({ tr, state }) => {
|
||||||
|
let isVariable = false;
|
||||||
|
const { selection } = state;
|
||||||
|
const { empty, anchor } = selection;
|
||||||
|
|
||||||
|
if (!empty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => {
|
||||||
|
if (node.type.name === this.name) {
|
||||||
|
isVariable = true;
|
||||||
|
tr.insertText(
|
||||||
|
this.options.deleteTriggerWithBackspace
|
||||||
|
? ''
|
||||||
|
: this.options.suggestion.char || '',
|
||||||
|
pos,
|
||||||
|
pos + node.nodeSize,
|
||||||
|
);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return isVariable;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
Suggestion({
|
||||||
|
editor: this.editor,
|
||||||
|
...this.options.suggestion,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
@@ -0,0 +1,154 @@
|
|||||||
|
import { ReactRenderer } from '@tiptap/react';
|
||||||
|
import type { SuggestionOptions } from '@tiptap/suggestion';
|
||||||
|
import tippy, { type GetReferenceClientRect } from 'tippy.js';
|
||||||
|
|
||||||
|
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
|
||||||
|
import { cn } from '../../../lib/classname';
|
||||||
|
import type { VariableStorage, VariableType } from './VariableExtension';
|
||||||
|
|
||||||
|
export type VariableListProps = {
|
||||||
|
command: (variable: VariableType) => void;
|
||||||
|
items: VariableType[];
|
||||||
|
} & SuggestionOptions;
|
||||||
|
|
||||||
|
export const VariableList = forwardRef((props: VariableListProps, ref) => {
|
||||||
|
const { items, command } = props;
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
|
||||||
|
const selectItem = (index: number) => {
|
||||||
|
const item = props.items[index];
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
command(item);
|
||||||
|
};
|
||||||
|
|
||||||
|
const upHandler = () => {
|
||||||
|
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
const downHandler = () => {
|
||||||
|
setSelectedIndex((selectedIndex + 1) % items.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
const enterHandler = () => {
|
||||||
|
selectItem(selectedIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => setSelectedIndex(0), [items]);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
upHandler();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
downHandler();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
enterHandler();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5 overflow-auto rounded-lg border border-gray-200 bg-white p-1 shadow-sm">
|
||||||
|
{items.length ? (
|
||||||
|
items.map((item, index) => (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'rounded-md p-1 px-1.5 text-left text-sm hover:bg-gray-100',
|
||||||
|
index === selectedIndex && 'bg-gray-100',
|
||||||
|
)}
|
||||||
|
key={index}
|
||||||
|
onClick={() => selectItem(index)}
|
||||||
|
>
|
||||||
|
{item?.label}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md p-1 px-1.5 text-left text-sm">No result</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
VariableList.displayName = 'VariableList';
|
||||||
|
|
||||||
|
export function variableSuggestion(): Omit<SuggestionOptions, 'editor'> {
|
||||||
|
return {
|
||||||
|
items: ({ editor, query }) => {
|
||||||
|
const storage = editor.storage.variable as VariableStorage;
|
||||||
|
|
||||||
|
return storage.variables
|
||||||
|
.filter((variable) =>
|
||||||
|
variable.label.toLowerCase().startsWith(query.toLowerCase()),
|
||||||
|
)
|
||||||
|
.slice(0, 5);
|
||||||
|
},
|
||||||
|
|
||||||
|
render: () => {
|
||||||
|
let component: ReactRenderer<any>;
|
||||||
|
let popup: InstanceType<any> | null = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
onStart: (props) => {
|
||||||
|
component = new ReactRenderer(VariableList, {
|
||||||
|
props,
|
||||||
|
editor: props.editor,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!props.clientRect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
popup = tippy('body', {
|
||||||
|
getReferenceClientRect: props.clientRect as GetReferenceClientRect,
|
||||||
|
appendTo: () => document.body,
|
||||||
|
content: component.element,
|
||||||
|
showOnCreate: true,
|
||||||
|
interactive: true,
|
||||||
|
trigger: 'manual',
|
||||||
|
placement: 'top-start',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onUpdate(props) {
|
||||||
|
component.updateProps(props);
|
||||||
|
|
||||||
|
if (!props.clientRect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
popup[0].setProps({
|
||||||
|
getReferenceClientRect: props.clientRect,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onKeyDown(props) {
|
||||||
|
if (props.event.key === 'Escape') {
|
||||||
|
popup[0].hide();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return component.ref?.onKeyDown(props);
|
||||||
|
},
|
||||||
|
|
||||||
|
onExit() {
|
||||||
|
popup[0].destroy();
|
||||||
|
component.destroy();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
@@ -4,8 +4,7 @@ import { queryClient } from '../../stores/query-client';
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Spinner } from '../ReactIcons/Spinner';
|
import { Spinner } from '../ReactIcons/Spinner';
|
||||||
import { BotIcon, SendIcon } from 'lucide-react';
|
import { BotIcon, SendIcon } from 'lucide-react';
|
||||||
import TextareaAutosize from 'react-textarea-autosize';
|
import { ChatEditor } from '../ChatEditor/ChatEditor';
|
||||||
import { cn } from '../../lib/classname';
|
|
||||||
|
|
||||||
type RoadmapAIChatProps = {
|
type RoadmapAIChatProps = {
|
||||||
roadmapId: string;
|
roadmapId: string;
|
||||||
@@ -28,8 +27,8 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
|||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grow grid-cols-2">
|
<div className="grid grow grid-cols-3">
|
||||||
<div className="h-full overflow-y-auto">
|
<div className="col-span-2 h-full overflow-y-auto">
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
<Spinner
|
<Spinner
|
||||||
@@ -41,7 +40,7 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
|||||||
<div ref={roadmapContainerRef} />
|
<div ref={roadmapContainerRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col border-l border-gray-200 bg-white">
|
||||||
<div className="flex min-h-[46px] items-center justify-between gap-2 border-gray-200 px-3 py-2 text-sm">
|
<div className="flex min-h-[46px] items-center justify-between gap-2 border-gray-200 px-3 py-2 text-sm">
|
||||||
<span className="flex items-center gap-2 text-sm">
|
<span className="flex items-center gap-2 text-sm">
|
||||||
<BotIcon className="size-4 shrink-0 text-black" />
|
<BotIcon className="size-4 shrink-0 text-black" />
|
||||||
@@ -61,14 +60,11 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TextareaAutosize
|
<ChatEditor />
|
||||||
className="h-full min-h-[41px] grow resize-none bg-transparent px-4 py-2 focus:outline-hidden"
|
|
||||||
placeholder="Ask AI anything about the roadmap..."
|
|
||||||
autoFocus={true}
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="flex aspect-square size-[41px] items-center justify-center text-zinc-500 hover:text-black disabled:cursor-not-allowed disabled:opacity-50"
|
className="flex aspect-square size-[36px] items-center justify-center p-2 text-zinc-500 hover:text-black disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<SendIcon className="size-4 stroke-[2.5]" />
|
<SendIcon className="size-4 stroke-[2.5]" />
|
||||||
</button>
|
</button>
|
||||||
|
Reference in New Issue
Block a user