feat: implement course notes

feat/course
Arik Chakma 4 weeks ago
parent 0b75bcee32
commit e1b786011d
  1. 5
      package.json
  2. 567
      pnpm-lock.yaml
  3. 7
      src/components/Course/CourseLayout.tsx
  4. 41
      src/components/CourseNotes/CourseNoteCard.tsx
  5. 105
      src/components/CourseNotes/CourseNoteForm.tsx
  6. 67
      src/components/CourseNotes/CourseNotes.tsx
  7. 123
      src/components/CourseNotes/CourseNotesPopover.tsx
  8. 98
      src/components/CourseNotes/NoteContentBubbleMenu.tsx
  9. 96
      src/components/CourseNotes/NoteContentEditor.tsx
  10. 76
      src/hooks/use-course-note.ts
  11. 6
      src/styles/global.css

@ -44,6 +44,10 @@
"@napi-rs/image": "^1.9.2",
"@resvg/resvg-js": "^2.6.2",
"@tanstack/react-query": "^5.59.15",
"@tiptap/extension-text-style": "^2.9.1",
"@tiptap/extension-underline": "^2.9.1",
"@tiptap/react": "^2.9.1",
"@tiptap/starter-kit": "^2.9.1",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"astro": "^4.15.4",
@ -83,6 +87,7 @@
"sql.js": "^1.11.0",
"tailwind-merge": "^2.4.0",
"tailwindcss": "^3.4.7",
"tiptap-markdown": "^0.8.10",
"turndown": "^7.2.0",
"unified": "^11.0.5",
"zustand": "^4.5.4"

@ -53,6 +53,18 @@ importers:
'@tanstack/react-query':
specifier: ^5.59.15
version: 5.59.15(react@18.3.1)
'@tiptap/extension-text-style':
specifier: ^2.9.1
version: 2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))
'@tiptap/extension-underline':
specifier: ^2.9.1
version: 2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))
'@tiptap/react':
specifier: ^2.9.1
version: 2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))(@tiptap/pm@2.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@tiptap/starter-kit':
specifier: ^2.9.1
version: 2.9.1
'@types/react':
specifier: ^18.3.3
version: 18.3.8
@ -170,6 +182,9 @@ importers:
tailwindcss:
specifier: ^3.4.7
version: 3.4.13
tiptap-markdown:
specifier: ^0.8.10
version: 0.8.10(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))
turndown:
specifier: ^7.2.0
version: 7.2.0
@ -971,6 +986,9 @@ packages:
engines: {node: '>=18'}
hasBin: true
'@popperjs/core@2.11.8':
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
'@reactflow/background@11.3.14':
resolution: {integrity: sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==}
peerDependencies:
@ -1007,6 +1025,9 @@ packages:
react: '>=17'
react-dom: '>=17'
'@remirror/core-constants@3.0.0':
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
'@resvg/resvg-js-android-arm-eabi@2.6.2':
resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==}
engines: {node: '>= 10'}
@ -1205,6 +1226,142 @@ packages:
peerDependencies:
react: ^18 || ^19
'@tiptap/core@2.9.1':
resolution: {integrity: sha512-tifnLL/ARzQ6/FGEJjVwj9UT3v+pENdWHdk9x6F3X0mB1y0SeCjV21wpFLYESzwNdBPAj8NMp8Behv7dBnhIfw==}
peerDependencies:
'@tiptap/pm': ^2.7.0
'@tiptap/extension-blockquote@2.9.1':
resolution: {integrity: sha512-Y0jZxc/pdkvcsftmEZFyG+73um8xrx6/DMfgUcNg3JAM63CISedNcr+OEI11L0oFk1KFT7/aQ9996GM6Kubdqg==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/extension-bold@2.9.1':
resolution: {integrity: sha512-e2P1zGpnnt4+TyxTC5pX/lPxPasZcuHCYXY0iwQ3bf8qRQQEjDfj3X7EI+cXqILtnhOiviEOcYmeu5op2WhQDg==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/extension-bubble-menu@2.9.1':
resolution: {integrity: sha512-DWUF6NG08/bZDWw0jCeotSTvpkyqZTi4meJPomG9Wzs/Ol7mEwlNCsCViD999g0+IjyXFatBk4DfUq1YDDu++Q==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/extension-bullet-list@2.9.1':
resolution: {integrity: sha512-0hizL/0j9PragJObjAWUVSuGhN1jKjCFnhLQVRxtx4HutcvS/lhoWMvFg6ZF8xqWgIa06n6A7MaknQkqhTdhKA==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/extension-code-block@2.9.1':
resolution: {integrity: sha512-A/50wPWDqEUUUPhrwRKILP5gXMO5UlQ0F6uBRGYB9CEVOREam9yIgvONOnZVJtszHqOayjIVMXbH/JMBeq11/g==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/extension-code@2.9.1':
resolution: {integrity: sha512-WQqcVGe7i/E+yO3wz5XQteU1ETNZ00euUEl4ylVVmH2NM4Dh0KDjEhbhHlCM0iCfLUo7jhjC7dmS+hMdPUb+Tg==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/extension-document@2.9.1':
resolution: {integrity: sha512-1a+HCoDPnBttjqExfYLwfABq8MYdiowhy/wp8eCxVb6KGFEENO53KapstISvPzqH7eOi+qRjBB1KtVYb/ZXicg==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/extension-dropcursor@2.9.1':
resolution: {integrity: sha512-wJZspSmJRkDBtPkzFz1g7gvZOEOayk8s93UHsgbJxcV4VWHYleZ5XhT74sZunSjefNDm3qC6v2BSgLp3vNHVKQ==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/extension-floating-menu@2.9.1':
resolution: {integrity: sha512-MxZ7acNNsoNaKpetxfwi3Z11Bgrh0T2EJlCV77v9N1vWK38+st3H1WJanmLbPNtc2ocvhHJrz+DjDz3CWxQ9rQ==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/extension-gapcursor@2.9.1':
resolution: {integrity: sha512-jsRBmX01vr+5H02GljiHMo0n5H1vzoMLmFarxe0Yq2d2l9G/WV2VWX2XnGliqZAYWd1bI0phs7uLQIN3mxGQTw==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/extension-hard-break@2.9.1':
resolution: {integrity: sha512-fCuaOD/b7nDjm47PZ58oanq7y4ccS2wjPh42Qm0B0yipu/1fmC8eS1SmaXmk28F89BLtuL6uOCtR1spe+lZtlQ==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/extension-heading@2.9.1':
resolution: {integrity: sha512-SjZowzLixOFaCrV2cMaWi1mp8REK0zK1b3OcVx7bCZfVSmsOETJyrAIUpCKA8o60NwF7pwhBg0MN8oXlNKMeFw==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/extension-history@2.9.1':
resolution: {integrity: sha512-wp9qR1NM+LpvyLZFmdNaAkDq0d4jDJ7z7Fz7icFQPu31NVxfQYO3IXNmvJDCNu8hFAbImpA5aG8MBuwzRo0H9w==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/extension-horizontal-rule@2.9.1':
resolution: {integrity: sha512-ydUhABeaBI1CoJp+/BBqPhXINfesp1qMNL/jiDcMsB66fsD4nOyphpAJT7FaRFZFtQVF06+nttBtFZVkITQVqg==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/extension-italic@2.9.1':
resolution: {integrity: sha512-VkNA6Vz96+/+7uBlsgM7bDXXx4b62T1fDam/3UKifA72aD/fZckeWrbT7KrtdUbzuIniJSbA0lpTs5FY29+86Q==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/extension-list-item@2.9.1':
resolution: {integrity: sha512-6O4NtYNR5N2Txi4AC0/4xMRJq9xd4+7ShxCZCDVL0WDVX37IhaqMO7LGQtA6MVlYyNaX4W1swfdJaqrJJ5HIUw==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/extension-ordered-list@2.9.1':
resolution: {integrity: sha512-6J9jtv1XP8dW7/JNSH/K4yiOABc92tBJtgCsgP8Ep4+fjfjdj4HbjS1oSPWpgItucF2Fp/VF8qg55HXhjxHjTw==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/extension-paragraph@2.9.1':
resolution: {integrity: sha512-JOmT0xd4gd3lIhLwrsjw8lV+ZFROKZdIxLi0Ia05XSu4RLrrvWj0zdKMSB+V87xOWfSB3Epo95zAvnPox5Q16A==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/extension-strike@2.9.1':
resolution: {integrity: sha512-V5aEXdML+YojlPhastcu7w4biDPwmzy/fWq0T2qjfu5Te/THcqDmGYVBKESBm5x6nBy5OLkanw2O+KHu2quDdg==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/extension-text-style@2.9.1':
resolution: {integrity: sha512-LAxc0SeeiPiAVBwksczeA7BJSZb6WtVpYhy5Esvy9K0mK5kttB4KxtnXWeQzMIJZQbza65yftGKfQlexf/Y7yg==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/extension-text@2.9.1':
resolution: {integrity: sha512-3wo9uCrkLVLQFgbw2eFU37QAa1jq1/7oExa+FF/DVxdtHRS9E2rnUZ8s2hat/IWzvPUHXMwo3Zg2XfhoamQpCA==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/extension-underline@2.9.1':
resolution: {integrity: sha512-IrUsIqKPgD7GcAjr4D+RC0WvLHUDBTMkD8uPNEoeD1uH9t9zFyDfMRPnx/z3/6Gf6fTh3HzLcHGibiW2HiMi2A==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm@2.9.1':
resolution: {integrity: sha512-mvV86fr7kEuDYEApQ2uMPCKL2uagUE0BsXiyyz3KOkY1zifyVm1fzdkscb24Qy1GmLzWAIIihA+3UHNRgYdOlQ==}
'@tiptap/react@2.9.1':
resolution: {integrity: sha512-LQJ34ZPfXtJF36SZdcn4Fiwsl2WxZ9YRJI87OLnsjJ45O+gV/PfBzz/4ap+LF8LOS0AbbGhTTjBOelPoNm+aYA==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
react: ^17.0.0 || ^18.0.0
react-dom: ^17.0.0 || ^18.0.0
'@tiptap/starter-kit@2.9.1':
resolution: {integrity: sha512-nsw6UF/7wDpPfHRhtGOwkj1ipIEiWZS1VGw+c14K61vM1CNj0uQ4jogbHwHZqN1dlL5Hh+FCqUHDPxG6ECbijg==}
'@tybys/wasm-util@0.9.0':
resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==}
@ -1340,12 +1497,30 @@ packages:
'@types/js-cookie@3.0.6':
resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==}
'@types/linkify-it@3.0.5':
resolution: {integrity: sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==}
'@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
'@types/luxon@3.4.2':
resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==}
'@types/markdown-it@13.0.9':
resolution: {integrity: sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==}
'@types/markdown-it@14.1.2':
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
'@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
'@types/mdurl@1.0.5':
resolution: {integrity: sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==}
'@types/mdurl@2.0.0':
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
'@types/ms@0.7.34':
resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==}
@ -1391,6 +1566,9 @@ packages:
'@types/unist@3.0.3':
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.2.0':
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
@ -1869,6 +2047,10 @@ packages:
resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==}
engines: {node: '>=0.8.0'}
escape-string-regexp@4.0.0:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
escape-string-regexp@5.0.0:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'}
@ -1902,6 +2084,9 @@ packages:
extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-glob@3.3.2:
resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==}
engines: {node: '>=8.6.0'}
@ -2307,6 +2492,9 @@ packages:
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
engines: {node: '>=8'}
markdown-it-task-lists@2.1.1:
resolution: {integrity: sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==}
markdown-it@14.1.0:
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
hasBin: true
@ -2593,6 +2781,9 @@ packages:
resolution: {integrity: sha512-GQEkNkH/GHOhPFXcqZs3IDahXEQcQxsSjEkK4KvEEST4t7eNzoMjxTzef+EZ+JluDEV+Raoi3WQ2CflnRdSVnQ==}
engines: {node: '>=18'}
orderedmap@2.1.1:
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
@ -2819,6 +3010,64 @@ packages:
property-information@6.5.0:
resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==}
prosemirror-changeset@2.2.1:
resolution: {integrity: sha512-J7msc6wbxB4ekDFj+n9gTW/jav/p53kdlivvuppHsrZXCaQdVgRghoZbSS3kwrRyAstRVQ4/+u5k7YfLgkkQvQ==}
prosemirror-collab@1.3.1:
resolution: {integrity: sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==}
prosemirror-commands@1.6.2:
resolution: {integrity: sha512-0nDHH++qcf/BuPLYvmqZTUUsPJUCPBUXt0J1ErTcDIS369CTp773itzLGIgIXG4LJXOlwYCr44+Mh4ii6MP1QA==}
prosemirror-dropcursor@1.8.1:
resolution: {integrity: sha512-M30WJdJZLyXHi3N8vxN6Zh5O8ZBbQCz0gURTfPmTIBNQ5pxrdU7A58QkNqfa98YEjSAL1HUyyU34f6Pm5xBSGw==}
prosemirror-gapcursor@1.3.2:
resolution: {integrity: sha512-wtjswVBd2vaQRrnYZaBCbyDqr232Ed4p2QPtRIUK5FuqHYKGWkEwl08oQM4Tw7DOR0FsasARV5uJFvMZWxdNxQ==}
prosemirror-history@1.4.1:
resolution: {integrity: sha512-2JZD8z2JviJrboD9cPuX/Sv/1ChFng+xh2tChQ2X4bB2HeK+rra/bmJ3xGntCcjhOqIzSDG6Id7e8RJ9QPXLEQ==}
prosemirror-inputrules@1.4.0:
resolution: {integrity: sha512-6ygpPRuTJ2lcOXs9JkefieMst63wVJBgHZGl5QOytN7oSZs3Co/BYbc3Yx9zm9H37Bxw8kVzCnDsihsVsL4yEg==}
prosemirror-keymap@1.2.2:
resolution: {integrity: sha512-EAlXoksqC6Vbocqc0GtzCruZEzYgrn+iiGnNjsJsH4mrnIGex4qbLdWWNza3AW5W36ZRrlBID0eM6bdKH4OStQ==}
prosemirror-markdown@1.13.1:
resolution: {integrity: sha512-Sl+oMfMtAjWtlcZoj/5L/Q39MpEnVZ840Xo330WJWUvgyhNmLBLN7MsHn07s53nG/KImevWHSE6fEj4q/GihHw==}
prosemirror-menu@1.2.4:
resolution: {integrity: sha512-S/bXlc0ODQup6aiBbWVsX/eM+xJgCTAfMq/nLqaO5ID/am4wS0tTCIkzwytmao7ypEtjj39i7YbJjAgO20mIqA==}
prosemirror-model@1.23.0:
resolution: {integrity: sha512-Q/fgsgl/dlOAW9ILu4OOhYWQbc7TQd4BwKH/RwmUjyVf8682Be4zj3rOYdLnYEcGzyg8LL9Q5IWYKD8tdToreQ==}
prosemirror-schema-basic@1.2.3:
resolution: {integrity: sha512-h+H0OQwZVqMon1PNn0AG9cTfx513zgIG2DY00eJ00Yvgb3UD+GQ/VlWW5rcaxacpCGT1Yx8nuhwXk4+QbXUfJA==}
prosemirror-schema-list@1.4.1:
resolution: {integrity: sha512-jbDyaP/6AFfDfu70VzySsD75Om2t3sXTOdl5+31Wlxlg62td1haUpty/ybajSfJ1pkGadlOfwQq9kgW5IMo1Rg==}
prosemirror-state@1.4.3:
resolution: {integrity: sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==}
prosemirror-tables@1.6.0:
resolution: {integrity: sha512-eirSS2fwVYzKhvM2qeXSn9ix/SBn7QOLDftPQ4ImEQIevFDiSKAB6Lbrmm/WEgrbTDbCm+xhSq4gOD9w7wT59Q==}
prosemirror-trailing-node@3.0.0:
resolution: {integrity: sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==}
peerDependencies:
prosemirror-model: ^1.22.1
prosemirror-state: ^1.4.2
prosemirror-view: ^1.33.8
prosemirror-transform@1.10.2:
resolution: {integrity: sha512-2iUq0wv2iRoJO/zj5mv8uDUriOHWzXRnOTVgCzSXnktS/2iQRa3UUQwVlkBlYZFtygw6Nh1+X4mGqoYBINn5KQ==}
prosemirror-view@1.34.3:
resolution: {integrity: sha512-mKZ54PrX19sSaQye+sef+YjBbNu2voNwLS1ivb6aD2IRmxRGW64HU9B644+7OfJStGLyxvOreKqEgfvXa91WIA==}
punycode.js@2.3.1:
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
engines: {node: '>=6'}
@ -2965,6 +3214,9 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
rope-sequence@1.3.4:
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@ -3150,6 +3402,14 @@ packages:
tinyexec@0.3.0:
resolution: {integrity: sha512-tVGE0mVJPGb0chKhqmsoosjsS+qUnJVGJpZgsHYQcGoPlG3B51R3PouqTgEGH2Dc9jjFyOqOpix6ZHNMXp1FZg==}
tippy.js@6.3.7:
resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==}
tiptap-markdown@0.8.10:
resolution: {integrity: sha512-iDVkR2BjAqkTDtFX0h94yVvE2AihCXlF0Q7RIXSJPRSR5I0PA1TMuAg6FHFpmqTn4tPxJ0by0CK7PUMlnFLGEQ==}
peerDependencies:
'@tiptap/core': ^2.0.3
to-fast-properties@2.0.0:
resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
engines: {node: '>=4'}
@ -4074,6 +4334,8 @@ snapshots:
dependencies:
playwright: 1.47.2
'@popperjs/core@2.11.8': {}
'@reactflow/background@11.3.14(@types/react@18.3.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@reactflow/core': 11.11.4(@types/react@18.3.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -4152,6 +4414,8 @@ snapshots:
- '@types/react'
- immer
'@remirror/core-constants@3.0.0': {}
'@resvg/resvg-js-android-arm-eabi@2.6.2':
optional: true
@ -4306,6 +4570,164 @@ snapshots:
'@tanstack/query-core': 5.59.13
react: 18.3.1
'@tiptap/core@2.9.1(@tiptap/pm@2.9.1)':
dependencies:
'@tiptap/pm': 2.9.1
'@tiptap/extension-blockquote@2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))':
dependencies:
'@tiptap/core': 2.9.1(@tiptap/pm@2.9.1)
'@tiptap/extension-bold@2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))':
dependencies:
'@tiptap/core': 2.9.1(@tiptap/pm@2.9.1)
'@tiptap/extension-bubble-menu@2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))(@tiptap/pm@2.9.1)':
dependencies:
'@tiptap/core': 2.9.1(@tiptap/pm@2.9.1)
'@tiptap/pm': 2.9.1
tippy.js: 6.3.7
'@tiptap/extension-bullet-list@2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))':
dependencies:
'@tiptap/core': 2.9.1(@tiptap/pm@2.9.1)
'@tiptap/extension-code-block@2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))(@tiptap/pm@2.9.1)':
dependencies:
'@tiptap/core': 2.9.1(@tiptap/pm@2.9.1)
'@tiptap/pm': 2.9.1
'@tiptap/extension-code@2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))':
dependencies:
'@tiptap/core': 2.9.1(@tiptap/pm@2.9.1)
'@tiptap/extension-document@2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))':
dependencies:
'@tiptap/core': 2.9.1(@tiptap/pm@2.9.1)
'@tiptap/extension-dropcursor@2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))(@tiptap/pm@2.9.1)':
dependencies:
'@tiptap/core': 2.9.1(@tiptap/pm@2.9.1)
'@tiptap/pm': 2.9.1
'@tiptap/extension-floating-menu@2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))(@tiptap/pm@2.9.1)':
dependencies:
'@tiptap/core': 2.9.1(@tiptap/pm@2.9.1)
'@tiptap/pm': 2.9.1
tippy.js: 6.3.7
'@tiptap/extension-gapcursor@2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))(@tiptap/pm@2.9.1)':
dependencies:
'@tiptap/core': 2.9.1(@tiptap/pm@2.9.1)
'@tiptap/pm': 2.9.1
'@tiptap/extension-hard-break@2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))':
dependencies:
'@tiptap/core': 2.9.1(@tiptap/pm@2.9.1)
'@tiptap/extension-heading@2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))':
dependencies:
'@tiptap/core': 2.9.1(@tiptap/pm@2.9.1)
'@tiptap/extension-history@2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))(@tiptap/pm@2.9.1)':
dependencies:
'@tiptap/core': 2.9.1(@tiptap/pm@2.9.1)
'@tiptap/pm': 2.9.1
'@tiptap/extension-horizontal-rule@2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))(@tiptap/pm@2.9.1)':
dependencies:
'@tiptap/core': 2.9.1(@tiptap/pm@2.9.1)
'@tiptap/pm': 2.9.1
'@tiptap/extension-italic@2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))':
dependencies:
'@tiptap/core': 2.9.1(@tiptap/pm@2.9.1)
'@tiptap/extension-list-item@2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))':
dependencies:
'@tiptap/core': 2.9.1(@tiptap/pm@2.9.1)
'@tiptap/extension-ordered-list@2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))':
dependencies:
'@tiptap/core': 2.9.1(@tiptap/pm@2.9.1)
'@tiptap/extension-paragraph@2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))':
dependencies:
'@tiptap/core': 2.9.1(@tiptap/pm@2.9.1)
'@tiptap/extension-strike@2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))':
dependencies:
'@tiptap/core': 2.9.1(@tiptap/pm@2.9.1)
'@tiptap/extension-text-style@2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))':
dependencies:
'@tiptap/core': 2.9.1(@tiptap/pm@2.9.1)
'@tiptap/extension-text@2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))':
dependencies:
'@tiptap/core': 2.9.1(@tiptap/pm@2.9.1)
'@tiptap/extension-underline@2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))':
dependencies:
'@tiptap/core': 2.9.1(@tiptap/pm@2.9.1)
'@tiptap/pm@2.9.1':
dependencies:
prosemirror-changeset: 2.2.1
prosemirror-collab: 1.3.1
prosemirror-commands: 1.6.2
prosemirror-dropcursor: 1.8.1
prosemirror-gapcursor: 1.3.2
prosemirror-history: 1.4.1
prosemirror-inputrules: 1.4.0
prosemirror-keymap: 1.2.2
prosemirror-markdown: 1.13.1
prosemirror-menu: 1.2.4
prosemirror-model: 1.23.0
prosemirror-schema-basic: 1.2.3
prosemirror-schema-list: 1.4.1
prosemirror-state: 1.4.3
prosemirror-tables: 1.6.0
prosemirror-trailing-node: 3.0.0(prosemirror-model@1.23.0)(prosemirror-state@1.4.3)(prosemirror-view@1.34.3)
prosemirror-transform: 1.10.2
prosemirror-view: 1.34.3
'@tiptap/react@2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))(@tiptap/pm@2.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@tiptap/core': 2.9.1(@tiptap/pm@2.9.1)
'@tiptap/extension-bubble-menu': 2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))(@tiptap/pm@2.9.1)
'@tiptap/extension-floating-menu': 2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))(@tiptap/pm@2.9.1)
'@tiptap/pm': 2.9.1
'@types/use-sync-external-store': 0.0.6
fast-deep-equal: 3.1.3
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
use-sync-external-store: 1.2.2(react@18.3.1)
'@tiptap/starter-kit@2.9.1':
dependencies:
'@tiptap/core': 2.9.1(@tiptap/pm@2.9.1)
'@tiptap/extension-blockquote': 2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))
'@tiptap/extension-bold': 2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))
'@tiptap/extension-bullet-list': 2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))
'@tiptap/extension-code': 2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))
'@tiptap/extension-code-block': 2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))(@tiptap/pm@2.9.1)
'@tiptap/extension-document': 2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))
'@tiptap/extension-dropcursor': 2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))(@tiptap/pm@2.9.1)
'@tiptap/extension-gapcursor': 2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))(@tiptap/pm@2.9.1)
'@tiptap/extension-hard-break': 2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))
'@tiptap/extension-heading': 2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))
'@tiptap/extension-history': 2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))(@tiptap/pm@2.9.1)
'@tiptap/extension-horizontal-rule': 2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))(@tiptap/pm@2.9.1)
'@tiptap/extension-italic': 2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))
'@tiptap/extension-list-item': 2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))
'@tiptap/extension-ordered-list': 2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))
'@tiptap/extension-paragraph': 2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))
'@tiptap/extension-strike': 2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))
'@tiptap/extension-text': 2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))
'@tiptap/extension-text-style': 2.9.1(@tiptap/core@2.9.1(@tiptap/pm@2.9.1))
'@tiptap/pm': 2.9.1
'@tybys/wasm-util@0.9.0':
dependencies:
tslib: 2.7.0
@ -4471,12 +4893,30 @@ snapshots:
'@types/js-cookie@3.0.6': {}
'@types/linkify-it@3.0.5': {}
'@types/linkify-it@5.0.0': {}
'@types/luxon@3.4.2': {}
'@types/markdown-it@13.0.9':
dependencies:
'@types/linkify-it': 3.0.5
'@types/mdurl': 1.0.5
'@types/markdown-it@14.1.2':
dependencies:
'@types/linkify-it': 5.0.0
'@types/mdurl': 2.0.0
'@types/mdast@4.0.4':
dependencies:
'@types/unist': 3.0.3
'@types/mdurl@1.0.5': {}
'@types/mdurl@2.0.0': {}
'@types/ms@0.7.34': {}
'@types/nlcst@2.0.3':
@ -4528,6 +4968,8 @@ snapshots:
'@types/unist@3.0.3': {}
'@types/use-sync-external-store@0.0.6': {}
'@ungap/structured-clone@1.2.0': {}
'@vitejs/plugin-react@4.3.1(vite@5.4.7(@types/node@18.19.50))':
@ -5062,6 +5504,8 @@ snapshots:
escape-string-regexp@1.0.5: {}
escape-string-regexp@4.0.0: {}
escape-string-regexp@5.0.0: {}
esprima@4.0.1: {}
@ -5084,6 +5528,8 @@ snapshots:
extend@3.0.2: {}
fast-deep-equal@3.1.3: {}
fast-glob@3.3.2:
dependencies:
'@nodelib/fs.stat': 2.0.5
@ -5517,6 +5963,8 @@ snapshots:
dependencies:
semver: 6.3.1
markdown-it-task-lists@2.1.1: {}
markdown-it@14.1.0:
dependencies:
argparse: 2.0.1
@ -5973,6 +6421,8 @@ snapshots:
string-width: 7.2.0
strip-ansi: 7.1.0
orderedmap@2.1.1: {}
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
@ -6134,6 +6584,109 @@ snapshots:
property-information@6.5.0: {}
prosemirror-changeset@2.2.1:
dependencies:
prosemirror-transform: 1.10.2
prosemirror-collab@1.3.1:
dependencies:
prosemirror-state: 1.4.3
prosemirror-commands@1.6.2:
dependencies:
prosemirror-model: 1.23.0
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.2
prosemirror-dropcursor@1.8.1:
dependencies:
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.2
prosemirror-view: 1.34.3
prosemirror-gapcursor@1.3.2:
dependencies:
prosemirror-keymap: 1.2.2
prosemirror-model: 1.23.0
prosemirror-state: 1.4.3
prosemirror-view: 1.34.3
prosemirror-history@1.4.1:
dependencies:
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.2
prosemirror-view: 1.34.3
rope-sequence: 1.3.4
prosemirror-inputrules@1.4.0:
dependencies:
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.2
prosemirror-keymap@1.2.2:
dependencies:
prosemirror-state: 1.4.3
w3c-keyname: 2.2.8
prosemirror-markdown@1.13.1:
dependencies:
'@types/markdown-it': 14.1.2
markdown-it: 14.1.0
prosemirror-model: 1.23.0
prosemirror-menu@1.2.4:
dependencies:
crelt: 1.0.6
prosemirror-commands: 1.6.2
prosemirror-history: 1.4.1
prosemirror-state: 1.4.3
prosemirror-model@1.23.0:
dependencies:
orderedmap: 2.1.1
prosemirror-schema-basic@1.2.3:
dependencies:
prosemirror-model: 1.23.0
prosemirror-schema-list@1.4.1:
dependencies:
prosemirror-model: 1.23.0
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.2
prosemirror-state@1.4.3:
dependencies:
prosemirror-model: 1.23.0
prosemirror-transform: 1.10.2
prosemirror-view: 1.34.3
prosemirror-tables@1.6.0:
dependencies:
prosemirror-keymap: 1.2.2
prosemirror-model: 1.23.0
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.2
prosemirror-view: 1.34.3
prosemirror-trailing-node@3.0.0(prosemirror-model@1.23.0)(prosemirror-state@1.4.3)(prosemirror-view@1.34.3):
dependencies:
'@remirror/core-constants': 3.0.0
escape-string-regexp: 4.0.0
prosemirror-model: 1.23.0
prosemirror-state: 1.4.3
prosemirror-view: 1.34.3
prosemirror-transform@1.10.2:
dependencies:
prosemirror-model: 1.23.0
prosemirror-view@1.34.3:
dependencies:
prosemirror-model: 1.23.0
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.2
punycode.js@2.3.1: {}
queue-microtask@1.2.3: {}
@ -6353,6 +6906,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.22.4
fsevents: 2.3.3
rope-sequence@1.3.4: {}
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
@ -6599,6 +7154,18 @@ snapshots:
tinyexec@0.3.0: {}
tippy.js@6.3.7:
dependencies:
'@popperjs/core': 2.11.8
tiptap-markdown@0.8.10(@tiptap/core@2.9.1(@tiptap/pm@2.9.1)):
dependencies:
'@tiptap/core': 2.9.1(@tiptap/pm@2.9.1)
'@types/markdown-it': 13.0.9
markdown-it: 14.1.0
markdown-it-task-lists: 2.1.1
prosemirror-markdown: 1.13.1
to-fast-properties@2.0.0: {}
to-regex-range@5.0.1:

@ -139,7 +139,12 @@ export function CourseLayout(props: CourseLayoutProps) {
{activeChapterId && activeLessonId && (
<footer className="flex items-center justify-between border-t border-zinc-800 px-4">
<div className="flex items-center gap-2">
<CourseNotes />
<CourseNotes
courseId={activeCourseId}
chapters={chapters}
currentChapterId={activeChapterId}
currentLessonId={activeLessonId}
/>
</div>
<div className="flex items-center gap-2">
<button

@ -1,5 +1,6 @@
import { useMemo } from 'react';
import { markdownToHtml } from '../../lib/markdown';
import { ArrowRight } from 'lucide-react';
type CourseNoteCardProps = {
courseId: string;
@ -10,11 +11,20 @@ type CourseNoteCardProps = {
lessonTitle: string;
content: string;
onLearnMoreClick: () => void;
};
export function CourseNoteCard(props: CourseNoteCardProps) {
const { chapterTitle, lessonTitle, content, courseId, chapterId, lessonId } =
props;
const {
chapterTitle,
lessonTitle,
content,
courseId,
chapterId,
lessonId,
onLearnMoreClick,
} = props;
const markdownHTML = useMemo(() => {
const html = markdownToHtml(content, false);
@ -24,15 +34,24 @@ export function CourseNoteCard(props: CourseNoteCardProps) {
}, [content]);
return (
<div className="flex flex-col gap-3 p-4">
<div className="flex max-w-max items-center gap-1 rounded-full border border-zinc-700 px-3 py-1 text-xs text-zinc-200">
<a className="underline-offset-2 hover:text-white hover:underline">
{chapterTitle}
</a>
<span className="text-zinc-400">/</span>
<a className="underline-offset-2 hover:text-white hover:underline">
{lessonTitle}
</a>
<div className="relative flex flex-col gap-3 p-4">
<div className="flex items-center justify-between gap-2">
<div className="flex max-w-max items-center gap-1 rounded-full border border-zinc-700 px-3 py-1 text-xs text-zinc-200">
<a className="underline-offset-2 hover:text-white hover:underline">
{chapterTitle}
</a>
<span className="text-zinc-400">/</span>
<a className="underline-offset-2 hover:text-white hover:underline">
{lessonTitle}
</a>
</div>
<button
className="flex items-center gap-1 text-sm text-zinc-400 hover:text-white"
onClick={onLearnMoreClick}
>
Learn More <ArrowRight className="size-4" />
</button>
</div>
<div

@ -0,0 +1,105 @@
import { useState } from 'react';
import {
useDeleteCourseNoteMutation,
useUpsertCourseNoteMutation,
type CourseNoteDocument,
} from '../../hooks/use-course-note';
import { NoteContentEditor } from './NoteContentEditor';
import { Loader2 } from 'lucide-react';
type CourseNoteFormProps = {
courseId: string;
currentChapterId: string;
currentLessonId: string;
note?: CourseNoteDocument;
onCancelClick?: () => void;
};
export function CourseNoteForm(props: CourseNoteFormProps) {
const {
note: defaultNote,
onCancelClick,
courseId,
currentChapterId,
currentLessonId,
} = props;
const [content, setContent] = useState<string>(defaultNote?.content || '');
const upsertNote = useUpsertCourseNoteMutation(courseId);
const deleteNote = useDeleteCourseNoteMutation(courseId);
return (
<>
<div className="flex min-h-[41px] items-center justify-between gap-2 border-b border-zinc-700 px-4 py-2 text-sm">
<button
className="text-zinc-400 underline-offset-2 hover:text-white hover:underline"
onClick={onCancelClick}
disabled={upsertNote.isPending}
>
Cancel
</button>
<div className="flex items-center gap-2">
{defaultNote?._id && (
<button
className="flex items-center gap-1 text-red-400 underline-offset-2 hover:text-red-500 hover:underline"
disabled={upsertNote.isPending || deleteNote.isPending}
onClick={() => {
deleteNote.mutate(defaultNote?._id, {
onSuccess: () => {
onCancelClick?.();
},
});
}}
>
Delete
</button>
)}
<button
className="flex items-center gap-1 text-zinc-400 underline-offset-2 hover:text-white hover:underline disabled:opacity-60"
onClick={() => {
upsertNote.mutate(
{
...(defaultNote?._id ? { id: defaultNote?._id } : {}),
chapterId: defaultNote?.chapterId || currentChapterId,
lessonId: defaultNote?.lessonId || currentLessonId,
content,
},
{
onSuccess: () => {
if (defaultNote?._id) {
return;
}
onCancelClick?.();
},
},
);
}}
disabled={!content || upsertNote.isPending || deleteNote.isPending}
>
{upsertNote.isPending && (
<Loader2 className="size-3 animate-spin stroke-[2.5]" />
)}
Save
</button>
</div>
</div>
<div className="relative grow overflow-y-auto [scrollbar-color:#3f3f46_#27272a;]">
<div className="absolute inset-0 flex h-full flex-col justify-stretch">
<NoteContentEditor
defaultContent={defaultNote?.content}
onMount={(editor) => {
editor?.commands.focus();
setContent(editor?.storage?.markdown.getMarkdown() || '');
}}
onUpdate={(editor) => {
setContent(editor?.storage?.markdown.getMarkdown() || '');
}}
/>
</div>
</div>
</>
);
}

@ -1,49 +1,38 @@
import { MessageSquareCode } from 'lucide-react';
import { Loader2, MessageSquareCode } from 'lucide-react';
import { CourseNoteCard } from './CourseNoteCard';
import { CourseNoteForm } from './CourseNoteForm';
import { useMemo, useState } from 'react';
import { useListCourseNote } from '../../hooks/use-course-note';
import type { ChapterFileType } from '../../lib/course';
import { CourseNotesPopover } from './CourseNotesPopover';
type CourseNotesProps = {
courseId: string;
currentChapterId: string;
currentLessonId: string;
chapters: ChapterFileType[];
};
export function CourseNotes(props: CourseNotesProps) {
const [isOpen, setIsOpen] = useState(false);
export function CourseNotes() {
return (
<div className="relative">
<button className="flex items-center gap-1 rounded-lg border border-zinc-800 px-2 py-1.5 text-sm leading-none disabled:opacity-60">
<button
className="flex items-center gap-1 rounded-lg border border-zinc-800 px-2 py-1.5 text-sm leading-none disabled:opacity-60"
onClick={() => setIsOpen(!isOpen)}
>
<MessageSquareCode className="size-4 stroke-[2.5]" />
Take Notes
</button>
<div className="absolute bottom-full left-0 z-10 flex h-[60dvh] w-[420px] -translate-y-2 flex-col rounded-xl border border-zinc-700 bg-zinc-800 text-white">
<div className="flex items-center justify-between gap-2 border-b border-zinc-700 px-4 py-2 text-sm">
<h4 className="text-base font-medium">Course Notes</h4>
<button className="text-zinc-400 underline-offset-2 hover:text-white hover:underline">
+ New Note
</button>
</div>
<div className="relative grow overflow-y-auto [scrollbar-color:#3f3f46_#27272a;]">
<div className="absolute inset-0 flex flex-col divide-y divide-zinc-700">
<div className="flex flex-col gap-3 p-4">
<div className="flex max-w-max items-center gap-1 rounded-full border border-zinc-700 px-3 py-1 text-xs text-zinc-200">
<a className="underline-offset-2 hover:text-white hover:underline">
Date Definition Language
</a>
<span className="text-zinc-400">/</span>
<a className="underline-offset-2 hover:text-white hover:underline">
Creating Tabel
</a>
</div>
<div className="course-content prose prose-sm prose-invert line-clamp-3">
<p>
This is an example of a note containing multiple elements.
Markdown allows you to easily format text for{' '}
<em>documentation</em> and <strong>notes</strong>.
</p>
<p>
Use <code>console.log()</code> to print messages to the
console.
</p>
</div>
</div>
</div>
</div>
</div>
{isOpen && (
<CourseNotesPopover
{...props}
onOutsideClick={() => setIsOpen(false)}
/>
)}
</div>
);
}

@ -0,0 +1,123 @@
import { useMemo, useRef, useState } from 'react';
import { useListCourseNote } from '../../hooks/use-course-note';
import type { ChapterFileType } from '../../lib/course';
import { CourseNoteForm } from './CourseNoteForm';
import { CourseNoteCard } from './CourseNoteCard';
import { Loader2 } from 'lucide-react';
import { useOutsideClick } from '../../hooks/use-outside-click';
type CourseNotesPopoverProps = {
courseId: string;
currentChapterId: string;
currentLessonId: string;
chapters: ChapterFileType[];
onOutsideClick?: () => void;
};
export function CourseNotesPopover(props: CourseNotesPopoverProps) {
const {
courseId,
chapters,
currentChapterId,
currentLessonId,
onOutsideClick,
} = props;
const { data: notes, isLoading } = useListCourseNote(courseId);
const containerRef = useRef<HTMLDivElement | null>(null);
const [isCreatingNote, setIsCreatingNote] = useState(false);
const [updatingNoteId, setUpdatingNoteId] = useState<string | null>(null);
const enrichedNotes = useMemo(() => {
if (!notes) {
return [];
}
return notes.map((note) => {
const chapter = chapters.find((c) => c.id === note.chapterId);
const lesson = chapter?.lessons.find((l) => l.id === note.lessonId);
return {
...note,
chapterTitle: chapter?.frontmatter?.title || '',
lessonTitle: lesson?.frontmatter?.title || '',
};
});
}, [notes]);
const updatingCourseNote = notes?.find((note) => note._id === updatingNoteId);
useOutsideClick(containerRef, () => {
if (isCreatingNote || updatingCourseNote) {
setIsCreatingNote(false);
setUpdatingNoteId(null);
}
onOutsideClick?.();
});
return (
<div
className="absolute bottom-full left-0 z-10 flex h-[60dvh] w-[420px] -translate-y-2 flex-col overflow-hidden rounded-xl border border-zinc-700 bg-zinc-800 text-white"
ref={containerRef}
>
{(isCreatingNote || updatingCourseNote) && (
<CourseNoteForm
courseId={courseId}
currentChapterId={currentChapterId}
currentLessonId={currentLessonId}
note={updatingCourseNote}
onCancelClick={() => {
setIsCreatingNote(false);
setUpdatingNoteId(null);
}}
/>
)}
{!isCreatingNote && !updatingNoteId && (
<>
<div className="flex items-center justify-between gap-2 border-b border-zinc-700 px-4 py-2 text-sm">
<h4 className="text-base font-medium">Course Notes</h4>
<button
className="text-zinc-400 underline-offset-2 hover:text-white hover:underline"
onClick={() => setIsCreatingNote(true)}
>
+ New Note
</button>
</div>
<div className="relative grow overflow-y-auto [scrollbar-color:#3f3f46_#27272a;]">
<div className="absolute inset-0 flex flex-col divide-y divide-zinc-700">
{isLoading && (
<div className="flex h-full items-center justify-center text-zinc-400">
<Loader2 className="size-5 animate-spin stroke-[2.5]" />
</div>
)}
{!isLoading && enrichedNotes.length === 0 && (
<div className="flex h-full items-center justify-center text-zinc-400">
No notes available.
</div>
)}
{!isLoading &&
enrichedNotes.length > 0 &&
enrichedNotes.map((note, index) => (
<CourseNoteCard
key={index}
{...note}
onLearnMoreClick={() => {
setUpdatingNoteId(note._id);
}}
/>
))}
</div>
</div>
</>
)}
</div>
);
}

@ -0,0 +1,98 @@
import { type FC } from 'react';
import {
BubbleMenu,
type BubbleMenuProps,
isTextSelection,
} from '@tiptap/react';
import {
BoldIcon,
ItalicIcon,
StrikethroughIcon,
Underline,
} from 'lucide-react';
import { cn } from '../../lib/classname';
export interface BubbleMenuItem {
name: string;
isActive: () => boolean;
command: () => void;
icon?: typeof BoldIcon;
}
export type NoteContentBubbleMenuProps = Omit<BubbleMenuProps, 'children'>;
export const NoteContentBubbleMenu: FC<NoteContentBubbleMenuProps> = (
props,
) => {
const { editor } = props;
const items: BubbleMenuItem[] = [
{
name: 'bold',
isActive: () => editor?.isActive('bold')!,
command: () => editor?.chain().focus().toggleBold().run()!,
icon: BoldIcon,
},
{
name: 'italic',
isActive: () => editor?.isActive('italic')!,
command: () => editor?.chain().focus().toggleItalic().run()!,
icon: ItalicIcon,
},
{
name: 'underline',
isActive: () => editor?.isActive('underline')!,
command: () => editor?.chain().focus().toggleUnderline().run()!,
icon: Underline,
},
{
name: 'strike',
isActive: () => editor?.isActive('strike')!,
command: () => editor?.chain().focus().toggleStrike().run()!,
icon: StrikethroughIcon,
},
];
const bubbleMenuProps: NoteContentBubbleMenuProps = {
...props,
shouldShow: ({ editor, state, from, to }) => {
const { doc, selection } = state;
const { empty } = selection;
// Sometime check for `empty` is not enough.
// Doubleclick an empty paragraph returns a node size of 2.
// So we check also for an empty text size.
const isEmptyTextBlock =
!doc.textBetween(from, to).length && isTextSelection(state.selection);
if (empty || isEmptyTextBlock || !editor.isEditable) {
return false;
}
return true;
},
tippyOptions: {
moveTransition: 'transform 0.15s ease-out',
},
};
return (
<BubbleMenu
{...bubbleMenuProps}
className="flex gap-1 rounded-lg border border-zinc-700 bg-zinc-800 p-0.5"
>
{items.map((item, index) => (
<button
key={index}
onClick={item.command}
className={cn(
'flex h-7 w-7 items-center justify-center gap-2 rounded-md hover:bg-zinc-700',
item.isActive() && 'bg-zinc-700',
)}
>
{item.icon && <item.icon size={18} />}
</button>
))}
</BubbleMenu>
);
};

@ -0,0 +1,96 @@
import TextStyle from '@tiptap/extension-text-style';
import {
Editor as TiptapEditor,
EditorContent,
useEditor,
} from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { Markdown } from 'tiptap-markdown';
import { useEffect } from 'react';
import Underline from '@tiptap/extension-underline';
import { NoteContentBubbleMenu } from './NoteContentBubbleMenu';
type NoteContentEditorProps = {
onMount?: (editor: TiptapEditor) => void;
onUpdate?: (editor: TiptapEditor) => void;
defaultContent?: string;
};
export function NoteContentEditor(props: NoteContentEditorProps) {
const { onMount, defaultContent, onUpdate } = props;
const editor = useEditor({
extensions: [
TextStyle,
StarterKit.configure({
bulletList: {
keepMarks: true,
keepAttributes: false,
},
orderedList: {
keepMarks: true,
keepAttributes: false,
},
}),
Markdown,
Underline,
],
content: defaultContent || '',
editorProps: {
attributes: {
class:
'note-content-editor course-content p-4 focus:outline-none prose prose-sm prose-invert h-full',
},
},
onCreate(props) {
onMount?.(props?.editor as TiptapEditor);
},
onUpdate(props) {
onUpdate?.(props?.editor as TiptapEditor);
},
});
useEffect(() => {
if (!editor || !defaultContent) {
return;
}
editor?.commands.setContent(defaultContent);
}, [defaultContent, editor]);
if (!editor) {
return null;
}
return (
<>
<NoteContentBubbleMenu editor={editor} />
<EditorContent
editor={editor}
className="relative"
onKeyDown={(e) => {
// Prevent backspace from deleting the node
if (e.key === 'Backspace') {
e.stopPropagation();
// CMD + A prevent default
} else if (e.key === 'a' && (e.metaKey || e.ctrlKey)) {
e.stopPropagation();
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
e.stopPropagation();
} else if (e.key === 'z' && e.shiftKey && e.metaKey) {
e.stopPropagation();
}
}}
onCopy={(e) => {
e.stopPropagation();
}}
onCut={(e) => {
e.stopPropagation();
}}
onPaste={(e) => {
e.stopPropagation();
}}
/>
</>
);
}

@ -0,0 +1,76 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { isLoggedIn } from '../lib/jwt';
import { httpDelete, httpGet, httpPost } from '../lib/query-http';
import { queryClient } from '../stores/query-client';
export interface CourseNoteDocument {
_id: string;
userId: string;
courseProgressId: string;
courseId: string;
chapterId: string;
lessonId: string;
content: string;
createdAt: Date;
updatedAt: Date;
}
export type ListCourseNoteResponse = CourseNoteDocument[];
export function useListCourseNote(courseId: string) {
return useQuery(
{
queryKey: ['list-course-note', courseId],
queryFn: async () => {
return httpGet<ListCourseNoteResponse>(
`/v1-list-course-note/${courseId}`,
);
},
enabled: !!courseId && isLoggedIn(),
},
queryClient,
);
}
type UpsertCourseNoteBody = {
id?: string;
chapterId: string;
lessonId: string;
content: string;
};
export function useUpsertCourseNoteMutation(courseId: string) {
return useMutation(
{
mutationFn: async (data: UpsertCourseNoteBody) => {
return httpPost(`/v1-upsert-course-note/${courseId}`, data);
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: ['list-course-note', courseId],
});
},
},
queryClient,
);
}
export function useDeleteCourseNoteMutation(courseId: string) {
return useMutation(
{
mutationFn: async (courseNoteId: string) => {
return httpDelete(`/v1-delete-course-note/${courseId}/${courseNoteId}`);
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: ['list-course-note', courseId],
});
},
},
queryClient,
);
}

@ -85,6 +85,12 @@ a > code:before {
content: '`' !important;
}
.note-content-editor[contenteditable] ~ grammarly-extension,
.note-content-editor input ~ grammarly-extension,
.note-content-editor textarea ~ grammarly-extension {
display: none;
}
.sponsor-footer {
text-align: center;
font-weight: 600;

Loading…
Cancel
Save