notes upgrade
This commit is contained in:
Generated
+646
@@ -11,6 +11,8 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tiptap/react": "^3.27.0",
|
||||
"@tiptap/starter-kit": "^3.27.0",
|
||||
"chart.js": "^4.4.0",
|
||||
"docx": "^9.6.1",
|
||||
"express": "^5.2.1",
|
||||
@@ -529,6 +531,34 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
||||
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
||||
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.5",
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
|
||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
@@ -1019,6 +1049,435 @@
|
||||
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "3.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.27.0.tgz",
|
||||
"integrity": "sha512-X53TQUq2xYn21FOC526GlVIycnDkiN9XPYO/NEsg3hXS/SUs1Q6ZLtaM8y3Ox7d+bnTW6CpWKlfyvUWp3scKEw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/pm": "3.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-blockquote": {
|
||||
"version": "3.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.27.0.tgz",
|
||||
"integrity": "sha512-xQvy+n1m4oxv/QLjWdg+FrrKpkyQqU1qbLjVQTdQP/kczqvaJhjzbJFBc38K0vvzMUvBKH0x6sr3GQVl4D84wg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bold": {
|
||||
"version": "3.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.27.0.tgz",
|
||||
"integrity": "sha512-GbnnDVbChgp9KWv+6EBuNNYEvNVbtLPYXPm809DQPi7rvb2SI/LOzLgWgVKByQqhoh498WWVBhsg/pvQ9Va9tw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bubble-menu": {
|
||||
"version": "3.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.27.0.tgz",
|
||||
"integrity": "sha512-cy52iePYfzaUUBg3S+00KN+18KMZH+UIlE4wRTEBW/c7OA6M/nkrVkxHYfKeJ3OEbZsRYv+GiRzASchDJVVTZg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.27.0",
|
||||
"@tiptap/pm": "3.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bullet-list": {
|
||||
"version": "3.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.27.0.tgz",
|
||||
"integrity": "sha512-86x8y0IzhOnRLnq5P9eTE/kgs6j383fTME+h0uNfOFfkQmzR4GLph8nkdGraormzI3+GKQUQaO4k/6bE+PSVpg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code": {
|
||||
"version": "3.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.27.0.tgz",
|
||||
"integrity": "sha512-lKJqy75GRxiNncaaBs9lutsEuVQR7AKZGFfy9Sg2nRTweWLpD6eNhSHI4N04EJvTIjxZjHAe9fGU50ZhS2ONiA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code-block": {
|
||||
"version": "3.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.27.0.tgz",
|
||||
"integrity": "sha512-t4cQ093ZRumHG954WKwAVYoZC6bkDrZCP9uqjHWUe1wBMzhqAWQ1OuHfaHpMnzsrdtm/svDO9rB26zBTQ0yZ/Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.27.0",
|
||||
"@tiptap/pm": "3.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-document": {
|
||||
"version": "3.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.27.0.tgz",
|
||||
"integrity": "sha512-xE+rUAPAA+65Usxbn5OoPVh0I0FSPz5dYprj+uo1mogPgqpcPLGVNMMoRLZ4WdiZ3I451d5+U7CUynIjD/iikw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-dropcursor": {
|
||||
"version": "3.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.27.0.tgz",
|
||||
"integrity": "sha512-unWa4Fs2p081Dh/VC9rShby6MqVuom5FdOuLa3JKio6PufsT+Arb87yP6bdsiyUHmWb8ZXXGtxzlCTEt/bm/TA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "3.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-floating-menu": {
|
||||
"version": "3.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.27.0.tgz",
|
||||
"integrity": "sha512-S9NV/3f1IFkPtIUClLUsJj98AOL3PrYMhPAH/b7SFtsvvfp2QXfL+I12ykPqOcPmj4KXw0Zc40E0QKg67M3pjw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@floating-ui/dom": "^1.0.0",
|
||||
"@tiptap/core": "3.27.0",
|
||||
"@tiptap/pm": "3.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-gapcursor": {
|
||||
"version": "3.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.27.0.tgz",
|
||||
"integrity": "sha512-tHIUQmtebBytVpd2f5oCUMAivdN5Yj8zRDpkA5uT3x38s9OdXLjLLYZHDD7b0ANQnN0r1vkxPXi9YF6+2XWZvA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "3.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-hard-break": {
|
||||
"version": "3.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.27.0.tgz",
|
||||
"integrity": "sha512-/DDTnvuW1/3qGfX5VKEFwTDHV1/ks5jMFXgMPlHXb/ASo+kb4HEohzuUYcO74Y3vtU2zENWJ2UBqsJ96TkjgOA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-heading": {
|
||||
"version": "3.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.27.0.tgz",
|
||||
"integrity": "sha512-KDN/8RyL3/VoXpSzRtNQ3hsreBUbHgMGmpqJpRvbykQ6wJ/W421uHfZR/hCsB3RNRB0kHZJdw4hDLfP73Ff6Ig==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-horizontal-rule": {
|
||||
"version": "3.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.27.0.tgz",
|
||||
"integrity": "sha512-04Xga9CqIqzKb1cqDk9AV9pTbtleqF+o8X3bb3n7HDgplYHxLjHId6RCzhfSZU6U8VZZF/RJQ1jbTebeYqGSBw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.27.0",
|
||||
"@tiptap/pm": "3.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-italic": {
|
||||
"version": "3.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.27.0.tgz",
|
||||
"integrity": "sha512-z/EomYhfaf9oxtYz4+yxaIO6cw9Sa2CVw7jsnGR0jf0CZk6B+5iTbNeJDMxpSeYg+dAPOWYiO8vWOeKM0gYPFQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-link": {
|
||||
"version": "3.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.27.0.tgz",
|
||||
"integrity": "sha512-c0fxb1cccM/1JNk7HOoJytKJ9kcpKPeGq86FNHQkqf+Bn+IzPgLb8Q3W89lOp33NlCOx2xiSrUFMBWcPX34z8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"linkifyjs": "^4.3.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.27.0",
|
||||
"@tiptap/pm": "3.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list": {
|
||||
"version": "3.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.27.0.tgz",
|
||||
"integrity": "sha512-27L9zP3SeKxW8DBfXcJtVnlfW6lJr6xm8C/WJ/iSlts/JHv1GwWwE7FWErmpkGeX6FwLnuaZbTMbfyyTlm4Dtg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.27.0",
|
||||
"@tiptap/pm": "3.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list-item": {
|
||||
"version": "3.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.27.0.tgz",
|
||||
"integrity": "sha512-e2yb7Vjh5YV2ELEwxAu3EQr4fKJPwfAWAgbZbUuEOh8FX8R+bnJyBW9oWwxnivwPsxVPtYseWuKNEJb7wW+c8w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list-keymap": {
|
||||
"version": "3.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.27.0.tgz",
|
||||
"integrity": "sha512-GV8miiDOTjSduesrO4arzC7F5T78vCy0YeVmtegtA2u5w8RBjKx3sRbS45IBF08bf4GRMRGbpGeLnqe/O0rZHQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-ordered-list": {
|
||||
"version": "3.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.27.0.tgz",
|
||||
"integrity": "sha512-hiI8xcAPGOgCny8cg2md708adIdjIe2C2nNh/6TROVGfpm/tAWK13jwE1sgY+eLZTsILqpBLsEUsdF9zoS0XqQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-paragraph": {
|
||||
"version": "3.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.27.0.tgz",
|
||||
"integrity": "sha512-miy096NqJJwD3ulGnOleXEIYP+pOICd5A9M3Zqy1Jpc7mw0+/YMZeR5JAXqdsYV3eq/pgYpykKlF4k+Gi84RYw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-strike": {
|
||||
"version": "3.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.27.0.tgz",
|
||||
"integrity": "sha512-srDgYCxu2OdYGB8rtJraYzrXCvj5qRmyrSG4NObVfDYNoMuVdkbNlxA8/LNJeKKZ9H8PZlrspvmxM4F91Lvb9g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text": {
|
||||
"version": "3.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.27.0.tgz",
|
||||
"integrity": "sha512-3ZiEeDDbvhvXwqSre62yxhe0Rc7t2+BGSNoVCCUFPsNUNvBL9s9bOwwnOG4fPZNBCyF38qBnTxblDwJ92E4AGw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-underline": {
|
||||
"version": "3.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.27.0.tgz",
|
||||
"integrity": "sha512-0w3GDF5rC0oZXYh6jBt8jPS+SQ5mtJsyDjP94A0Qjh/7tsfeDzCzZMAadE4i+9g6DI1Hce4GqYDAAfPkoj4y5Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extensions": {
|
||||
"version": "3.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.27.0.tgz",
|
||||
"integrity": "sha512-G4ufNa/F4upDI/2YWFBCVBs4skcCeQ2jvRQiFrLJCitUFcl03evP0znAvx5JKj9+nfm4ILYpPpncEFyMq4mFEg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.27.0",
|
||||
"@tiptap/pm": "3.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/pm": {
|
||||
"version": "3.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.27.0.tgz",
|
||||
"integrity": "sha512-cWuyaY19SoTOGwdPzhNrUuFMMILuR7mq/Ec02FO+y5jUnQiJYOy9pVx4RUIjBT24LYXrvA9YRhxL1v+KQKTz3A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.3.0",
|
||||
"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.3",
|
||||
"prosemirror-model": "^1.25.7",
|
||||
"prosemirror-schema-list": "^1.5.0",
|
||||
"prosemirror-state": "^1.4.4",
|
||||
"prosemirror-tables": "^1.8.0",
|
||||
"prosemirror-transform": "^1.12.0",
|
||||
"prosemirror-view": "^1.41.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/react": {
|
||||
"version": "3.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.27.0.tgz",
|
||||
"integrity": "sha512-C7FSa5yNPKaCll/Bvm0GmlJh2VLVJXNpKxC9I/L5BJdeVWXV3Prbt56U9FFYd8hjydFZ32GF3jUYQlVYHxnwHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"fast-equals": "^5.3.3",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tiptap/extension-bubble-menu": "^3.27.0",
|
||||
"@tiptap/extension-floating-menu": "^3.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.27.0",
|
||||
"@tiptap/pm": "3.27.0",
|
||||
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/starter-kit": {
|
||||
"version": "3.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.27.0.tgz",
|
||||
"integrity": "sha512-AFrNuIpeyr2FJsWP5sjD0uhJfPBHdEKBByrqLu4O9h/OI8qJYKaPo9PxMXA2jQlWgDq6z0r8GPrr3PbJCMe1JA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tiptap/core": "^3.27.0",
|
||||
"@tiptap/extension-blockquote": "^3.27.0",
|
||||
"@tiptap/extension-bold": "^3.27.0",
|
||||
"@tiptap/extension-bullet-list": "^3.27.0",
|
||||
"@tiptap/extension-code": "^3.27.0",
|
||||
"@tiptap/extension-code-block": "^3.27.0",
|
||||
"@tiptap/extension-document": "^3.27.0",
|
||||
"@tiptap/extension-dropcursor": "^3.27.0",
|
||||
"@tiptap/extension-gapcursor": "^3.27.0",
|
||||
"@tiptap/extension-hard-break": "^3.27.0",
|
||||
"@tiptap/extension-heading": "^3.27.0",
|
||||
"@tiptap/extension-horizontal-rule": "^3.27.0",
|
||||
"@tiptap/extension-italic": "^3.27.0",
|
||||
"@tiptap/extension-link": "^3.27.0",
|
||||
"@tiptap/extension-list": "^3.27.0",
|
||||
"@tiptap/extension-list-item": "^3.27.0",
|
||||
"@tiptap/extension-list-keymap": "^3.27.0",
|
||||
"@tiptap/extension-ordered-list": "^3.27.0",
|
||||
"@tiptap/extension-paragraph": "^3.27.0",
|
||||
"@tiptap/extension-strike": "^3.27.0",
|
||||
"@tiptap/extension-text": "^3.27.0",
|
||||
"@tiptap/extension-underline": "^3.27.0",
|
||||
"@tiptap/extensions": "^3.27.0",
|
||||
"@tiptap/pm": "^3.27.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
@@ -1121,6 +1580,12 @@
|
||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz",
|
||||
@@ -2640,6 +3105,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-equals": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
|
||||
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-json-stable-stringify": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||
@@ -3604,6 +4078,12 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/linkifyjs": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
|
||||
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@@ -4746,6 +5226,12 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/orderedmap": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/otplib": {
|
||||
"version": "13.4.0",
|
||||
"resolved": "https://registry.npmjs.org/otplib/-/otplib-13.4.0.tgz",
|
||||
@@ -4993,6 +5479,145 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-changeset": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
|
||||
"integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-transform": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-commands": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
|
||||
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.10.2"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-dropcursor": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
|
||||
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0",
|
||||
"prosemirror-view": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-gapcursor": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
|
||||
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-keymap": "^1.0.0",
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-view": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-history": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
|
||||
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.2.2",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
"prosemirror-view": "^1.31.0",
|
||||
"rope-sequence": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-inputrules": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
|
||||
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-keymap": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
|
||||
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"w3c-keyname": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-model": {
|
||||
"version": "1.25.9",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.9.tgz",
|
||||
"integrity": "sha512-pRTklkDDMMRopyoAcrr9wV/8g/RYgrLHBuJAb5hlEuYZRdm5yqmPjWId83fpBwPpSFqEdja0H7Dfd7z1X/npcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"orderedmap": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-schema-list": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
|
||||
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-state": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
"prosemirror-view": "^1.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-tables": {
|
||||
"version": "1.8.5",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
|
||||
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-keymap": "^1.2.3",
|
||||
"prosemirror-model": "^1.25.4",
|
||||
"prosemirror-state": "^1.4.4",
|
||||
"prosemirror-transform": "^1.10.5",
|
||||
"prosemirror-view": "^1.41.4"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-transform": {
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
|
||||
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-view": {
|
||||
"version": "1.41.9",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.9.tgz",
|
||||
"integrity": "sha512-clTunTX+eaLbr87L1V1QPheRlEQJyTlL3gXe9x3jQIk3rL0RVWxviDGz8tFaydwIVm+hKhYCyr+R/zBtWr9s6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.25.8",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@@ -5477,6 +6102,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rope-sequence": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
|
||||
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/router": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||
@@ -6169,6 +6800,15 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -6304,6 +6944,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tiptap/react": "^3.27.0",
|
||||
"@tiptap/starter-kit": "^3.27.0",
|
||||
"chart.js": "^4.4.0",
|
||||
"docx": "^9.6.1",
|
||||
"express": "^5.2.1",
|
||||
|
||||
+419
-3
@@ -386,6 +386,17 @@
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-secondary:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -719,6 +730,78 @@
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* Shared shimmer mixin used by multiple skeleton components */
|
||||
.episode-embed-skeleton {
|
||||
width: 100%;
|
||||
height: 152px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0.04) 25%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.04) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.4s infinite;
|
||||
}
|
||||
|
||||
/* Q&A skeleton cards */
|
||||
.qa-skeleton-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.qa-skeleton-card {
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255,255,255,0.07);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.qa-skeleton-line {
|
||||
height: 14px;
|
||||
border-radius: 6px;
|
||||
width: 100%;
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0.04) 25%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.04) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.4s infinite;
|
||||
}
|
||||
|
||||
.qa-skeleton-line--short { width: 38%; }
|
||||
.qa-skeleton-line--medium { width: 68%; }
|
||||
|
||||
/* Certificate loading skeleton */
|
||||
.cert-skeleton-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 60vh;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.cert-skeleton-card {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
padding: 3rem 2.5rem;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cert-skeleton-line {
|
||||
height: 14px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0.04) 25%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.04) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.4s infinite;
|
||||
}
|
||||
|
||||
.cert-skeleton-line--short { width: 40%; }
|
||||
.cert-skeleton-line--medium { width: 65%; }
|
||||
.cert-skeleton-line--title { width: 80%; height: 28px; border-radius: 8px; }
|
||||
|
||||
.platform-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
@@ -1490,6 +1573,23 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.study-lesson-announcement-close {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.3rem;
|
||||
line-height: 1;
|
||||
color: #3a2800;
|
||||
opacity: 0.65;
|
||||
padding: 0.2rem 0.4rem;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 150ms;
|
||||
}
|
||||
|
||||
.study-lesson-announcement-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.section-study-course-hero {
|
||||
padding: 6.5rem 0 3rem;
|
||||
background: linear-gradient(135deg, rgba(201, 168, 76, 0.08) 0%, rgba(201, 168, 76, 0.03) 40%, transparent 100%);
|
||||
@@ -2483,6 +2583,261 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Anchor note buttons (per lesson block) ────────────────────── */
|
||||
.study-class-block--anchored {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.note-anchor-btn {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: transparent;
|
||||
border: 1px solid #3a3828;
|
||||
border-radius: 6px;
|
||||
color: #5a5440;
|
||||
padding: 0.2rem 0.55rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: color 150ms, border-color 150ms, background 150ms;
|
||||
line-height: 1;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.note-anchor-btn:hover {
|
||||
color: #e0c070;
|
||||
border-color: rgba(224, 192, 112, 0.45);
|
||||
background: rgba(224, 192, 112, 0.06);
|
||||
}
|
||||
|
||||
.note-anchor-btn--has-note {
|
||||
color: #c9a84c;
|
||||
border-color: rgba(201, 168, 76, 0.35);
|
||||
}
|
||||
|
||||
.note-anchor-btn--open {
|
||||
color: #e0c070;
|
||||
background: rgba(224, 192, 112, 0.1);
|
||||
border-color: rgba(224, 192, 112, 0.5);
|
||||
}
|
||||
|
||||
/* ── Sidebar anchor list ─────────────────────────────────────────── */
|
||||
.note-anchor-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.note-anchor-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: transparent;
|
||||
border: 1px solid #27231b;
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem 0.7rem;
|
||||
color: #7a7060;
|
||||
font-size: 0.83rem;
|
||||
font-family: var(--brand-font-body);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: color 150ms, border-color 150ms, background 150ms;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.note-anchor-list-item:hover {
|
||||
color: #f0ead8;
|
||||
border-color: #3a3828;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.note-anchor-list-item--has {
|
||||
color: #c9a84c;
|
||||
border-color: rgba(201, 168, 76, 0.3);
|
||||
}
|
||||
|
||||
.note-anchor-list-item--open {
|
||||
color: #e0c070;
|
||||
background: rgba(224, 192, 112, 0.07);
|
||||
border-color: rgba(224, 192, 112, 0.45);
|
||||
}
|
||||
|
||||
.note-anchor-dot {
|
||||
font-size: 0.45rem;
|
||||
color: #c9a84c;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── NoteCard (WYSIWYG inline editor) ────────────────────────────── */
|
||||
.note-card {
|
||||
margin-top: 1.25rem;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(224, 192, 112, 0.22);
|
||||
background: #14130f;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.note-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.6rem 1rem;
|
||||
border-bottom: 1px solid #27231b;
|
||||
}
|
||||
|
||||
.note-card-label {
|
||||
color: #c9a84c;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
font-family: var(--brand-font-body);
|
||||
}
|
||||
|
||||
.note-card-autosave {
|
||||
color: #7a9c6a;
|
||||
font-size: 0.78rem;
|
||||
font-family: var(--brand-font-body);
|
||||
}
|
||||
|
||||
.note-card-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #6a6050;
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
padding: 0 0.2rem;
|
||||
line-height: 1;
|
||||
transition: color 150ms;
|
||||
}
|
||||
|
||||
.note-card-close:hover {
|
||||
color: #e0c070;
|
||||
}
|
||||
|
||||
.note-card-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-bottom: 1px solid #27231b;
|
||||
}
|
||||
|
||||
.note-tool {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 5px;
|
||||
color: #8a8070;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.88rem;
|
||||
font-family: var(--brand-font-body);
|
||||
cursor: pointer;
|
||||
transition: color 150ms, background 150ms, border-color 150ms;
|
||||
min-width: 28px;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.note-tool:hover:not(:disabled) {
|
||||
color: #f0ead8;
|
||||
background: #1f1d19;
|
||||
}
|
||||
|
||||
.note-tool:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.note-tool--active {
|
||||
color: #e0c070;
|
||||
background: rgba(224, 192, 112, 0.12);
|
||||
border-color: rgba(224, 192, 112, 0.28);
|
||||
}
|
||||
|
||||
.note-tool-divider {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: #2a2518;
|
||||
margin: 0 0.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.note-card-editor {
|
||||
padding: 0.85rem 1rem;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.note-card-editor .tiptap {
|
||||
outline: none;
|
||||
color: #f0ead8;
|
||||
font-family: var(--brand-font-body);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.note-card-editor .tiptap p {
|
||||
margin: 0 0 0.5em;
|
||||
}
|
||||
|
||||
.note-card-editor .tiptap p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.note-card-editor .tiptap ul,
|
||||
.note-card-editor .tiptap ol {
|
||||
padding-left: 1.4rem;
|
||||
margin: 0 0 0.5em;
|
||||
}
|
||||
|
||||
.note-card-editor .tiptap li {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.note-card-editor .tiptap strong {
|
||||
color: #f4edd5;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.note-card-editor .tiptap em {
|
||||
color: #d4c898;
|
||||
}
|
||||
|
||||
.note-card-editor .tiptap .is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: #4a4535;
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* ── Notes page rich content display ─────────────────────────────── */
|
||||
.study-note-rich-content {
|
||||
color: #d8cca8;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.study-note-rich-content p {
|
||||
margin: 0 0 0.6em;
|
||||
}
|
||||
|
||||
.study-note-rich-content ul,
|
||||
.study-note-rich-content ol {
|
||||
padding-left: 1.5rem;
|
||||
margin: 0 0 0.6em;
|
||||
}
|
||||
|
||||
.study-note-rich-content li {
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.study-note-rich-content strong {
|
||||
color: #f0ead8;
|
||||
}
|
||||
|
||||
.study-note-rich-content em {
|
||||
color: #d4c898;
|
||||
}
|
||||
|
||||
.study-auth-box label {
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.06em;
|
||||
@@ -2537,6 +2892,25 @@
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.study-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(12, 11, 10, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.study-modal-box {
|
||||
width: min(540px, 100%);
|
||||
background: #1b1a16;
|
||||
border: 1px solid #3c3a31;
|
||||
border-radius: 18px;
|
||||
padding: 2.5rem 2rem;
|
||||
}
|
||||
|
||||
.study-detail-nav {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
@@ -2798,6 +3172,15 @@
|
||||
background: var(--brand-gold-light, #e0c070);
|
||||
}
|
||||
|
||||
@keyframes buffering-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.episode-audio-player__play--buffering {
|
||||
animation: buffering-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.episode-audio-player__play svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
@@ -3250,6 +3633,12 @@
|
||||
margin: 0.25rem 0 0 !important;
|
||||
}
|
||||
|
||||
.study-signup-field-error {
|
||||
font-size: 0.82rem;
|
||||
color: #e57373;
|
||||
margin: 0.2rem 0 0.1rem;
|
||||
}
|
||||
|
||||
.study-signup-success {
|
||||
font-size: 1.05rem !important;
|
||||
color: #81c784 !important;
|
||||
@@ -3658,6 +4047,26 @@
|
||||
color: var(--brand-gold);
|
||||
}
|
||||
|
||||
.download-clear-link {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: var(--brand-font-body);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--brand-muted);
|
||||
padding: 0.35rem 0.5rem;
|
||||
transition: color 140ms ease;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.download-clear-link:hover {
|
||||
color: var(--brand-warm-white);
|
||||
}
|
||||
|
||||
.download-empty-state {
|
||||
text-align: center;
|
||||
color: var(--brand-muted);
|
||||
@@ -5457,7 +5866,10 @@
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
display: none;
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
@@ -5466,13 +5878,17 @@
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
padding: 0.5rem 1rem 1rem;
|
||||
padding: 0 1rem;
|
||||
background: rgba(10, 10, 8, 0.98);
|
||||
border-bottom: 1px solid rgba(201, 168, 76, 0.2);
|
||||
transition: max-height 0.28s ease, opacity 0.22s ease, padding 0.28s ease;
|
||||
}
|
||||
|
||||
.header-nav--open {
|
||||
display: flex;
|
||||
max-height: 500px;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
padding: 0.5rem 1rem 1rem;
|
||||
}
|
||||
|
||||
.header-nav .header-nav-link {
|
||||
|
||||
+37
-13
@@ -795,6 +795,15 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) {
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
{activeTag !== null && (
|
||||
<button
|
||||
type="button"
|
||||
className="download-clear-link"
|
||||
onClick={() => { setActiveTag(null); setActiveType('all') }}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1497,20 +1506,24 @@ function EpisodeDetailPage({ content }: { content: SiteContent }) {
|
||||
<h1 className="episode-detail-title">{episode.title}</h1>
|
||||
{episode.summary && <p className="episode-detail-summary">{episode.summary}</p>}
|
||||
|
||||
{resolvedEmbedUrl && (
|
||||
{(episode.embedUrl || episode.url) && (
|
||||
<div className="episode-detail-embed">
|
||||
{resolvedEmbedUrl.includes('spotify.com') ? (
|
||||
<iframe
|
||||
src={resolvedEmbedUrl}
|
||||
title={episode.title}
|
||||
width="100%"
|
||||
height="152"
|
||||
frameBorder="0"
|
||||
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
|
||||
loading="lazy"
|
||||
/>
|
||||
{resolvedEmbedUrl ? (
|
||||
resolvedEmbedUrl.includes('spotify.com') ? (
|
||||
<iframe
|
||||
src={resolvedEmbedUrl}
|
||||
title={episode.title}
|
||||
width="100%"
|
||||
height="152"
|
||||
frameBorder="0"
|
||||
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<EpisodeAudioPlayer src={resolvedEmbedUrl} title={episode.title} size="full" spotifyUrl={episode.url || content.platformSpotifyUrl} />
|
||||
)
|
||||
) : (
|
||||
<EpisodeAudioPlayer src={resolvedEmbedUrl} title={episode.title} size="full" spotifyUrl={episode.url || content.platformSpotifyUrl} />
|
||||
<div className="episode-embed-skeleton" aria-label="Loading player…" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -1870,7 +1883,18 @@ function PublicCertificatePage() {
|
||||
)
|
||||
|
||||
if (status === 'loading') {
|
||||
return <main className="thanks-page"><div className="thanks-card"><p>Loading certificate…</p></div></main>
|
||||
return (
|
||||
<main className="thanks-page">
|
||||
<div className="cert-skeleton-wrap" aria-busy="true" aria-label="Loading certificate">
|
||||
<div className="cert-skeleton-card">
|
||||
<div className="cert-skeleton-line cert-skeleton-line--short" />
|
||||
<div className="cert-skeleton-line cert-skeleton-line--title" />
|
||||
<div className="cert-skeleton-line cert-skeleton-line--medium" />
|
||||
<div className="cert-skeleton-line cert-skeleton-line--short" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
if (status === 'not-found' || !cert) {
|
||||
return (
|
||||
|
||||
+345
-135
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState, useCallback } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState, useCallback } from 'react'
|
||||
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
import type { SiteContent, StudyProgram, StudySection } from './content'
|
||||
import { DEFAULT_COLOSSIANS_STUDY_SECTIONS } from './colossiansStudyData'
|
||||
@@ -6,6 +6,7 @@ import { usePageMeta } from './hooks/usePageMeta'
|
||||
import { Breadcrumbs } from './components/Breadcrumbs'
|
||||
import { StudyCertificate } from './components/StudyCertificate'
|
||||
import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer'
|
||||
import { NoteCard } from './components/NoteCard'
|
||||
|
||||
type Props = { content: SiteContent }
|
||||
|
||||
@@ -83,6 +84,7 @@ type StudyCommunityPost = {
|
||||
message: string
|
||||
createdAt: string
|
||||
replies: StudyCommunityReply[]
|
||||
pending?: boolean
|
||||
}
|
||||
|
||||
function getLegacyColossiansStudy(content: SiteContent): StudyProgram {
|
||||
@@ -177,6 +179,11 @@ function isSectionReleased(section: StudySection): boolean {
|
||||
function getReleaseDateDisplay(section: StudySection): string {
|
||||
const date = getSectionReleaseDate(section)
|
||||
if (!date) return ''
|
||||
const now = new Date()
|
||||
const diffMs = date.getTime() - now.getTime()
|
||||
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24))
|
||||
if (diffDays === 1) return 'tomorrow'
|
||||
if (diffDays > 1 && diffDays <= 14) return `in ${diffDays} days`
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
@@ -184,6 +191,32 @@ function getNoteId(studySlug: string, sectionId: string) {
|
||||
return `${studySlug}--${sectionId}`
|
||||
}
|
||||
|
||||
const NOTE_ANCHORS = [
|
||||
{ id: 'scripture', label: 'Scripture Text' },
|
||||
{ id: 'commentary', label: 'Commentary' },
|
||||
{ id: 'questions', label: 'Reflection Questions' },
|
||||
] as const
|
||||
|
||||
type NoteAnchorId = typeof NOTE_ANCHORS[number]['id'] | 'general'
|
||||
|
||||
const ANCHOR_LABELS: Record<string, string> = {
|
||||
scripture: 'Scripture Text',
|
||||
commentary: 'Commentary',
|
||||
questions: 'Reflection Questions',
|
||||
general: 'General Notes',
|
||||
}
|
||||
|
||||
function parseNoteMap(raw: string): Record<string, string> {
|
||||
if (!raw?.trim()) return {}
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, string>
|
||||
}
|
||||
} catch {}
|
||||
return { general: raw }
|
||||
}
|
||||
|
||||
function getDisplayName(auth: StudyAuthState) {
|
||||
return auth.displayName?.trim() || auth.username.split('@')[0] || 'student'
|
||||
}
|
||||
@@ -259,16 +292,34 @@ function CommunityBoard({
|
||||
setSubmitting(true)
|
||||
setMessage('')
|
||||
setError('')
|
||||
const optimisticId = `pending-${Date.now()}`
|
||||
const optimisticPost: StudyCommunityPost = {
|
||||
id: optimisticId,
|
||||
studySlug: study.slug,
|
||||
sectionId: activeSection,
|
||||
authorName: communityDisplayName,
|
||||
authorAvatarUrl: auth.avatarUrl || undefined,
|
||||
message: newPost,
|
||||
createdAt: new Date().toISOString(),
|
||||
replies: [],
|
||||
pending: true,
|
||||
}
|
||||
setPosts(prev => [optimisticPost, ...prev])
|
||||
const postedMessage = newPost
|
||||
setNewPost('')
|
||||
try {
|
||||
const data = await readJson<{ post?: StudyCommunityPost }>('/api/study-community/posts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ studySlug: study.slug, sectionId: activeSection, message: newPost }),
|
||||
body: JSON.stringify({ studySlug: study.slug, sectionId: activeSection, message: postedMessage }),
|
||||
})
|
||||
if (data.post) setPosts(prev => [data.post!, ...prev])
|
||||
setNewPost('')
|
||||
setMessage('Posted to the community.')
|
||||
setPosts(prev => data.post
|
||||
? prev.map(p => p.id === optimisticId ? { ...data.post!, pending: false } : p)
|
||||
: prev.filter(p => p.id !== optimisticId))
|
||||
setMessage('')
|
||||
} catch (err) {
|
||||
setPosts(prev => prev.filter(p => p.id !== optimisticId))
|
||||
setNewPost(postedMessage)
|
||||
setError(err instanceof Error ? err.message : 'Unable to post right now.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
@@ -350,8 +401,13 @@ function CommunityBoard({
|
||||
rows={4}
|
||||
value={newPost}
|
||||
onChange={e => setNewPost(e.target.value)}
|
||||
onInput={e => {
|
||||
const el = e.currentTarget
|
||||
el.style.height = 'auto'
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}}
|
||||
placeholder={activeSection ? `Share a thought about ${releasedSections.find(s => s.id === activeSection)?.title ?? 'this lesson'}...` : 'Share a thought with the community...'}
|
||||
style={{ width: '100%', padding: '0.85rem 1rem', borderRadius: '12px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '0.75rem' }}
|
||||
style={{ width: '100%', padding: '0.85rem 1rem', borderRadius: '12px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '0.75rem', resize: 'none', overflow: 'hidden', boxSizing: 'border-box' }}
|
||||
/>
|
||||
<div className="study-auth-actions">
|
||||
<button type="button" className="btn-primary" disabled={submitting || !newPost.trim()} onClick={submitPost}>
|
||||
@@ -373,7 +429,7 @@ function CommunityBoard({
|
||||
) : visiblePosts.map(post => {
|
||||
const postSection = study.sections.find(s => s.id === post.sectionId)
|
||||
return (
|
||||
<div key={post.id} style={{ border: '1px solid #2a2518', borderRadius: '14px', padding: '1.15rem', background: '#11100d' }}>
|
||||
<div key={post.id} style={{ border: '1px solid #2a2518', borderRadius: '14px', padding: '1.15rem', background: '#11100d', opacity: post.pending ? 0.65 : 1, transition: 'opacity 300ms' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap', marginBottom: '0.4rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.65rem', flexWrap: 'wrap' }}>
|
||||
{post.authorAvatarUrl ? (
|
||||
@@ -407,8 +463,13 @@ function CommunityBoard({
|
||||
rows={3}
|
||||
value={replyDrafts[post.id] ?? ''}
|
||||
onChange={e => setReplyDrafts(prev => ({ ...prev, [post.id]: e.target.value }))}
|
||||
onInput={e => {
|
||||
const el = e.currentTarget
|
||||
el.style.height = 'auto'
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}}
|
||||
placeholder={`Reply to ${post.authorName || communityDisplayName}...`}
|
||||
style={{ width: '100%', padding: '0.8rem 1rem', borderRadius: '12px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '0.75rem' }}
|
||||
style={{ width: '100%', padding: '0.8rem 1rem', borderRadius: '12px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '0.75rem', resize: 'none', overflow: 'hidden' }}
|
||||
/>
|
||||
<button type="button" className="btn-primary" style={{ fontSize: '0.9rem' }} disabled={submitting || !(replyDrafts[post.id] ?? '').trim()} onClick={() => submitReply(post.id)}>Post Reply</button>
|
||||
</div>
|
||||
@@ -474,6 +535,7 @@ export function StudyLandingPage({ content }: Props) {
|
||||
const [enrollBusySlug, setEnrollBusySlug] = useState('')
|
||||
const [enrollMessage, setEnrollMessage] = useState('')
|
||||
const [showNewsletterNudge, setShowNewsletterNudge] = useState(false)
|
||||
const [confirmLeaveSlug, setConfirmLeaveSlug] = useState<string | null>(null)
|
||||
const [newsletterNudgeDone, setNewsletterNudgeDone] = useState(false)
|
||||
|
||||
const studies = getStudies(content)
|
||||
@@ -647,14 +709,27 @@ export function StudyLandingPage({ content }: Props) {
|
||||
<h3 style={{ margin: '0.35rem 0 0' }}>{studyInfo.title}</h3>
|
||||
<p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem' }}>{studyInfo.completedLessons}/{studyInfo.totalLessons} lessons completed · {studyInfo.noteCount} notes</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-admin-remove"
|
||||
disabled={enrollBusySlug === studyInfo.slug}
|
||||
onClick={() => handleEnrollmentToggle(studyInfo.slug, true)}
|
||||
>
|
||||
{enrollBusySlug === studyInfo.slug ? 'Working…' : 'Leave'}
|
||||
</button>
|
||||
{confirmLeaveSlug === studyInfo.slug ? (
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: '0.85rem', color: '#b9b09b' }}>Are you sure?</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-admin-remove"
|
||||
disabled={enrollBusySlug === studyInfo.slug}
|
||||
onClick={() => { setConfirmLeaveSlug(null); handleEnrollmentToggle(studyInfo.slug, true) }}
|
||||
>{enrollBusySlug === studyInfo.slug ? 'Working…' : 'Yes, leave'}</button>
|
||||
<button type="button" className="btn-secondary" style={{ fontSize: '0.85rem', padding: '0.3rem 0.75rem' }} onClick={() => setConfirmLeaveSlug(null)}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-admin-remove"
|
||||
disabled={enrollBusySlug === studyInfo.slug}
|
||||
onClick={() => setConfirmLeaveSlug(studyInfo.slug)}
|
||||
>
|
||||
Leave
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -675,14 +750,27 @@ export function StudyLandingPage({ content }: Props) {
|
||||
<h3 style={{ margin: '0.35rem 0 0' }}>{studyInfo.title}</h3>
|
||||
<p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem' }}>{studyInfo.completedLessons}/{studyInfo.totalLessons} lessons completed · {studyInfo.noteCount} notes</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={studyInfo.enrolled ? 'btn-admin-remove' : 'btn-primary'}
|
||||
disabled={enrollBusySlug === studyInfo.slug}
|
||||
onClick={() => handleEnrollmentToggle(studyInfo.slug, studyInfo.enrolled)}
|
||||
>
|
||||
{enrollBusySlug === studyInfo.slug ? 'Working…' : studyInfo.enrolled ? 'Leave' : 'Enroll'}
|
||||
</button>
|
||||
{studyInfo.enrolled && confirmLeaveSlug === studyInfo.slug ? (
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: '0.85rem', color: '#b9b09b' }}>Are you sure?</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-admin-remove"
|
||||
disabled={enrollBusySlug === studyInfo.slug}
|
||||
onClick={() => { setConfirmLeaveSlug(null); handleEnrollmentToggle(studyInfo.slug, true) }}
|
||||
>{enrollBusySlug === studyInfo.slug ? 'Working…' : 'Yes, leave'}</button>
|
||||
<button type="button" className="btn-secondary" style={{ fontSize: '0.85rem', padding: '0.3rem 0.75rem' }} onClick={() => setConfirmLeaveSlug(null)}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={studyInfo.enrolled ? 'btn-admin-remove' : 'btn-primary'}
|
||||
disabled={enrollBusySlug === studyInfo.slug}
|
||||
onClick={() => studyInfo.enrolled ? setConfirmLeaveSlug(studyInfo.slug) : handleEnrollmentToggle(studyInfo.slug, false)}
|
||||
>
|
||||
{enrollBusySlug === studyInfo.slug ? 'Working…' : studyInfo.enrolled ? 'Leave' : 'Enroll'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -907,6 +995,8 @@ export function StudyLandingPage({ content }: Props) {
|
||||
)
|
||||
}
|
||||
|
||||
const EMAIL_REGEX = /^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/
|
||||
|
||||
export function StudySignupPage() {
|
||||
const navigate = useNavigate()
|
||||
const [mode, setMode] = useState<'signup' | 'login'>('login')
|
||||
@@ -917,6 +1007,7 @@ export function StudySignupPage() {
|
||||
const [message, setMessage] = useState('')
|
||||
const [done, setDone] = useState(false)
|
||||
const [loggedInAs, setLoggedInAs] = useState('')
|
||||
const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string }>({})
|
||||
// 2FA state
|
||||
const [totpPendingToken, setTotpPendingToken] = useState('')
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
@@ -939,7 +1030,7 @@ export function StudySignupPage() {
|
||||
setMessage('Please enter your email and password.')
|
||||
return
|
||||
}
|
||||
if (!/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(email.trim())) {
|
||||
if (!EMAIL_REGEX.test(email.trim())) {
|
||||
setMessage('Please enter a valid email address.')
|
||||
return
|
||||
}
|
||||
@@ -994,11 +1085,11 @@ export function StudySignupPage() {
|
||||
|
||||
{done ? (
|
||||
<>
|
||||
<p className="study-signup-success">You're signed in as <strong>{loggedInAs}</strong>. Your notes are ready on every lesson.</p>
|
||||
<div className="study-signup-actions">
|
||||
<button type="button" className="btn-primary" onClick={() => navigate('/study')}>Go to Study Hub</button>
|
||||
<Link to="/study/account" className="btn-secondary">My Account</Link>
|
||||
<Link to="/study" className="btn-secondary">Browse Tracks</Link>
|
||||
<p className="study-signup-success">You're signed in as <strong>{loggedInAs}</strong>.</p>
|
||||
<p style={{ color: '#b09e79', marginBottom: '1.5rem', fontSize: '0.95rem' }}>Head to the study hub to enroll in a track and begin your first lesson.</p>
|
||||
<div className="study-signup-actions" style={{ flexDirection: 'column', alignItems: 'stretch', gap: '0.75rem' }}>
|
||||
<button type="button" className="btn-primary" style={{ justifyContent: 'center' }} onClick={() => navigate('/study')}>Browse & Enroll in a Study →</button>
|
||||
<Link to="/study/account" className="btn-secondary" style={{ justifyContent: 'center' }}>My Account</Link>
|
||||
</div>
|
||||
</>
|
||||
) : totpPendingToken ? (
|
||||
@@ -1059,21 +1150,37 @@ export function StudySignupPage() {
|
||||
id="signup-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
onChange={e => { setEmail(e.target.value); if (fieldErrors.email) setFieldErrors(prev => ({ ...prev, email: undefined })) }}
|
||||
onBlur={() => {
|
||||
if (email.trim() && !EMAIL_REGEX.test(email.trim())) {
|
||||
setFieldErrors(prev => ({ ...prev, email: 'Please enter a valid email address.' }))
|
||||
} else {
|
||||
setFieldErrors(prev => ({ ...prev, email: undefined }))
|
||||
}
|
||||
}}
|
||||
placeholder="your@email.com"
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
/>
|
||||
{fieldErrors.email && <p className="study-signup-field-error">{fieldErrors.email}</p>}
|
||||
<label htmlFor="signup-password">Password</label>
|
||||
<input
|
||||
id="signup-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
onChange={e => { setPassword(e.target.value); if (fieldErrors.password) setFieldErrors(prev => ({ ...prev, password: undefined })) }}
|
||||
onBlur={() => {
|
||||
if (mode === 'signup' && password && password.length < 8) {
|
||||
setFieldErrors(prev => ({ ...prev, password: 'Password must be at least 8 characters.' }))
|
||||
} else {
|
||||
setFieldErrors(prev => ({ ...prev, password: undefined }))
|
||||
}
|
||||
}}
|
||||
placeholder="At least 8 characters"
|
||||
autoComplete={mode === 'signup' ? 'new-password' : 'current-password'}
|
||||
onKeyDown={e => e.key === 'Enter' && submit()}
|
||||
/>
|
||||
{fieldErrors.password && <p className="study-signup-field-error">{fieldErrors.password}</p>}
|
||||
{mode === 'signup' && (
|
||||
<label className="contact-consent" style={{ marginTop: '0.75rem' }}>
|
||||
<input
|
||||
@@ -1381,19 +1488,24 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
const [enrollBusy, setEnrollBusy] = useState(false)
|
||||
const [enrollMessage, setEnrollMessage] = useState('')
|
||||
|
||||
const [noteText, setNoteText] = useState('')
|
||||
const [noteMap, setNoteMap] = useState<Record<string, string>>({})
|
||||
const [openAnchor, setOpenAnchor] = useState<NoteAnchorId | null>(null)
|
||||
const [noteLoading, setNoteLoading] = useState(false)
|
||||
const [noteSaving, setNoteSaving] = useState(false)
|
||||
const [noteMessage, setNoteMessage] = useState('')
|
||||
const [notesModalOpen, setNotesModalOpen] = useState(false)
|
||||
const [contextNoteMenu, setContextNoteMenu] = useState<{ x: number; y: number; text: string } | null>(null)
|
||||
const noteMapDirtyRef = useRef(false)
|
||||
const [contextNoteMenu, setContextNoteMenu] = useState<{ x: number; y: number; text: string; anchor: NoteAnchorId } | null>(null)
|
||||
const [completedSectionIds, setCompletedSectionIds] = useState<string[]>([])
|
||||
const [progressSaving, setProgressSaving] = useState(false)
|
||||
const [progressMessage, setProgressMessage] = useState('')
|
||||
const [checkpointAnswers, setCheckpointAnswers] = useState<Record<number, string>>({})
|
||||
const [showCelebration, setShowCelebration] = useState(false)
|
||||
const [bannerDismissed, setBannerDismissed] = useState(() => localStorage.getItem('study-banner-dismissed-v1') === '1')
|
||||
const [autoSaveMsg, setAutoSaveMsg] = useState('')
|
||||
const [_checkpointReflection, _setCheckpointReflection] = useState('')
|
||||
|
||||
const canSaveNote = auth.authenticated && !noteSaving && !noteLoading
|
||||
const savedNoteCount = Object.values(noteMap).filter(v => v?.trim()).length
|
||||
const lessonAudioEmbedUrl = useMemo(() => {
|
||||
const trimmed = section?.audioEmbedUrl?.trim() ?? ''
|
||||
if (!trimmed) return ''
|
||||
@@ -1461,7 +1573,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth.authenticated || !noteId || !isEnrolled) {
|
||||
setNoteText('')
|
||||
setNoteMap({})
|
||||
noteMapDirtyRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1472,7 +1585,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
readJson<{ note: string }>(`/api/study-notes/${encodeURIComponent(noteId)}`)
|
||||
.then(data => {
|
||||
if (cancelled) return
|
||||
setNoteText(data.note ?? '')
|
||||
setNoteMap(parseNoteMap(data.note ?? ''))
|
||||
noteMapDirtyRef.current = false
|
||||
})
|
||||
.catch(err => {
|
||||
if (cancelled) return
|
||||
@@ -1515,17 +1629,22 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
function updateNote(anchor: NoteAnchorId, html: string) {
|
||||
noteMapDirtyRef.current = true
|
||||
setNoteMap(prev => ({ ...prev, [anchor]: html }))
|
||||
}
|
||||
|
||||
async function saveNote() {
|
||||
if (!noteId || !canSaveNote) return
|
||||
setNoteSaving(true)
|
||||
setNoteMessage('')
|
||||
try {
|
||||
const data = await readJson<{ ok: boolean; note: string }>(`/api/study-notes/${encodeURIComponent(noteId)}`, {
|
||||
await readJson<{ ok: boolean; note: string }>(`/api/study-notes/${encodeURIComponent(noteId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ note: noteText }),
|
||||
body: JSON.stringify({ note: JSON.stringify(noteMap) }),
|
||||
})
|
||||
setNoteText(data.note ?? '')
|
||||
noteMapDirtyRef.current = false
|
||||
setNoteMessage('Notes saved.')
|
||||
} catch (err) {
|
||||
setNoteMessage(err instanceof Error ? err.message : 'Unable to save note.')
|
||||
@@ -1534,23 +1653,54 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
function closeNotesModal() {
|
||||
setNotesModalOpen(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!notesModalOpen) return
|
||||
if (!openAnchor) return
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') closeNotesModal()
|
||||
if (e.key === 'Escape') setOpenAnchor(null)
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => document.removeEventListener('keydown', onKeyDown)
|
||||
}, [notesModalOpen])
|
||||
}, [openAnchor])
|
||||
|
||||
function appendSelectedTextToNotes(selectedText: string) {
|
||||
useEffect(() => {
|
||||
if (!auth.authenticated || !noteId || noteLoading) return
|
||||
if (!noteMapDirtyRef.current) return
|
||||
const timer = setTimeout(async () => {
|
||||
if (!noteMapDirtyRef.current) return
|
||||
noteMapDirtyRef.current = false
|
||||
try {
|
||||
await readJson<{ ok: boolean }>(`/api/study-notes/${encodeURIComponent(noteId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ note: JSON.stringify(noteMap) }),
|
||||
})
|
||||
setAutoSaveMsg('Auto-saved.')
|
||||
setTimeout(() => setAutoSaveMsg(''), 3000)
|
||||
} catch {
|
||||
noteMapDirtyRef.current = true
|
||||
}
|
||||
}, 2000)
|
||||
return () => clearTimeout(timer)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [noteMap, noteId, auth.authenticated, noteLoading])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showCelebration) return
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setShowCelebration(false)
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => document.removeEventListener('keydown', onKeyDown)
|
||||
}, [showCelebration])
|
||||
|
||||
function appendSelectedTextToNotes(selectedText: string, anchor: NoteAnchorId = 'general') {
|
||||
if (!selectedText.trim()) return
|
||||
setNoteText(prev => prev ? `${prev.trim()}\n\n${selectedText.trim()}` : selectedText.trim())
|
||||
setNotesModalOpen(true)
|
||||
const existing = noteMap[anchor] ?? ''
|
||||
const appended = existing
|
||||
? `${existing}<p>${selectedText.trim()}</p>`
|
||||
: `<p>${selectedText.trim()}</p>`
|
||||
updateNote(anchor, appended)
|
||||
setOpenAnchor(anchor)
|
||||
}
|
||||
|
||||
function handleLessonContentContextMenu(e: React.MouseEvent<HTMLDivElement>) {
|
||||
@@ -1562,7 +1712,9 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
setContextNoteMenu({ x: e.clientX + 4, y: e.clientY + 4, text: selectedText })
|
||||
const anchorEl = (e.target as Element).closest('[data-note-anchor]')
|
||||
const anchor = (anchorEl?.getAttribute('data-note-anchor') ?? 'general') as NoteAnchorId
|
||||
setContextNoteMenu({ x: e.clientX + 4, y: e.clientY + 4, text: selectedText, anchor })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1572,6 +1724,15 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
return () => window.removeEventListener('mousedown', handleClick)
|
||||
}, [contextNoteMenu])
|
||||
|
||||
const releasedSectionCount = sections.filter(s => isSectionReleased(s)).length
|
||||
|
||||
function checkAndShowCelebration(newIds: string[], wasAlreadyComplete: boolean) {
|
||||
if (wasAlreadyComplete) return
|
||||
if (releasedSectionCount > 0 && newIds.length >= releasedSectionCount) {
|
||||
setShowCelebration(true)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCheckpoint() {
|
||||
if (!study || !section?.id || progressSaving) return
|
||||
const checkpointQuestions = section.checkpointQuestions ?? []
|
||||
@@ -1588,8 +1749,10 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
const data = await readJson<{ ok: boolean; completedSectionIds: string[] }>(`/api/study-progress/${encodeURIComponent(study.slug)}/${encodeURIComponent(section.id)}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
setCompletedSectionIds(Array.isArray(data.completedSectionIds) ? data.completedSectionIds : [])
|
||||
const newIds = Array.isArray(data.completedSectionIds) ? data.completedSectionIds : []
|
||||
setCompletedSectionIds(newIds)
|
||||
setProgressMessage('')
|
||||
checkAndShowCelebration(newIds, lessonCompleted)
|
||||
} catch (err) {
|
||||
setProgressMessage(err instanceof Error ? err.message : 'Unable to save. Please try again.')
|
||||
} finally {
|
||||
@@ -1689,13 +1852,36 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
|
||||
return (
|
||||
<main className="study-section-page" aria-label={section.title}>
|
||||
{section.announcement && (
|
||||
{section.announcement && !bannerDismissed && (
|
||||
<div className="study-lesson-announcement-banner" role="alert">
|
||||
<span className="study-lesson-announcement-icon" aria-hidden="true">📢</span>
|
||||
<div className="study-lesson-announcement-body">
|
||||
<strong className="study-lesson-announcement-label">Announcement</strong>
|
||||
<p className="study-lesson-announcement-text">{section.announcement}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="study-lesson-announcement-close"
|
||||
aria-label="Dismiss announcement"
|
||||
onClick={() => {
|
||||
setBannerDismissed(true)
|
||||
localStorage.setItem('study-banner-dismissed-v1', '1')
|
||||
}}
|
||||
>×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCelebration && study && (
|
||||
<div className="study-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="celebrate-heading">
|
||||
<div className="study-modal-box" style={{ textAlign: 'center', maxWidth: '480px' }}>
|
||||
<p style={{ fontSize: '2.5rem', margin: '0 0 0.5rem' }}>🎉</p>
|
||||
<h2 id="celebrate-heading" style={{ margin: '0 0 0.75rem' }}>You finished {study.title}!</h2>
|
||||
<p style={{ color: '#b09e79', marginBottom: '1.5rem' }}>Your certificate of completion is ready on the study dashboard.</p>
|
||||
<div className="study-auth-actions" style={{ justifyContent: 'center' }}>
|
||||
<Link to="/study/account" className="btn-primary" onClick={() => setShowCelebration(false)}>View Certificate</Link>
|
||||
<button type="button" className="btn-secondary" onClick={() => setShowCelebration(false)}>Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<section className="section-study-classroom" onContextMenu={handleLessonContentContextMenu}>
|
||||
@@ -1729,12 +1915,39 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
</article>
|
||||
)}
|
||||
|
||||
<article className="study-class-block">
|
||||
<article className="study-class-block study-class-block--anchored" data-note-anchor="scripture">
|
||||
{isEnrolled && (
|
||||
<button
|
||||
type="button"
|
||||
className={`note-anchor-btn${noteMap['scripture']?.trim() ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'scripture' ? ' note-anchor-btn--open' : ''}`}
|
||||
onClick={() => setOpenAnchor(curr => curr === 'scripture' ? null : 'scripture')}
|
||||
title={noteMap['scripture']?.trim() ? 'Edit scripture note' : 'Add note to Scripture'}
|
||||
aria-pressed={openAnchor === 'scripture'}
|
||||
>✎</button>
|
||||
)}
|
||||
<h2>Scripture Text</h2>
|
||||
<p className="study-detail-copy study-detail-copy--scripture">{section.passageText || `Add the passage text for ${section.reference} here when you move the guide online.`}</p>
|
||||
{isEnrolled && openAnchor === 'scripture' && (
|
||||
<NoteCard
|
||||
anchorLabel="Scripture Text"
|
||||
html={noteMap['scripture'] ?? ''}
|
||||
autoSaveMsg={autoSaveMsg}
|
||||
onChange={html => updateNote('scripture', html)}
|
||||
onClose={() => setOpenAnchor(null)}
|
||||
/>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<article className="study-class-block">
|
||||
<article className="study-class-block study-class-block--anchored" data-note-anchor="commentary">
|
||||
{isEnrolled && (
|
||||
<button
|
||||
type="button"
|
||||
className={`note-anchor-btn${noteMap['commentary']?.trim() ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'commentary' ? ' note-anchor-btn--open' : ''}`}
|
||||
onClick={() => setOpenAnchor(curr => curr === 'commentary' ? null : 'commentary')}
|
||||
title={noteMap['commentary']?.trim() ? 'Edit commentary note' : 'Add note to Commentary'}
|
||||
aria-pressed={openAnchor === 'commentary'}
|
||||
>✎</button>
|
||||
)}
|
||||
<h2>Instructor Commentary</h2>
|
||||
{section.commentary.split(/\n{2,}/).map((para, i) => (
|
||||
<p key={i} className="study-detail-copy">
|
||||
@@ -1743,9 +1956,27 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
))}
|
||||
</p>
|
||||
))}
|
||||
{isEnrolled && openAnchor === 'commentary' && (
|
||||
<NoteCard
|
||||
anchorLabel="Commentary"
|
||||
html={noteMap['commentary'] ?? ''}
|
||||
autoSaveMsg={autoSaveMsg}
|
||||
onChange={html => updateNote('commentary', html)}
|
||||
onClose={() => setOpenAnchor(null)}
|
||||
/>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<article className="study-class-block">
|
||||
<article className="study-class-block study-class-block--anchored" data-note-anchor="questions">
|
||||
{isEnrolled && (
|
||||
<button
|
||||
type="button"
|
||||
className={`note-anchor-btn${noteMap['questions']?.trim() ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'questions' ? ' note-anchor-btn--open' : ''}`}
|
||||
onClick={() => setOpenAnchor(curr => curr === 'questions' ? null : 'questions')}
|
||||
title={noteMap['questions']?.trim() ? 'Edit reflection note' : 'Add note to Reflection Questions'}
|
||||
aria-pressed={openAnchor === 'questions'}
|
||||
>✎</button>
|
||||
)}
|
||||
<h2>Reflection Questions</h2>
|
||||
<p className="study-detail-copy" style={{ marginBottom: '0.75rem', color: '#8a7f5a', fontSize: '0.9rem' }}>
|
||||
Work through these on your own, then share your thoughts in the Discussion below.
|
||||
@@ -1760,6 +1991,15 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
<Link to={`/study/${study.slug}/community`} className="btn-primary">Go to Community</Link>
|
||||
<Link to={`/contact?source=community&study=${encodeURIComponent(study.title)}`} className="btn-secondary">Ask Nate</Link>
|
||||
</div>
|
||||
{isEnrolled && openAnchor === 'questions' && (
|
||||
<NoteCard
|
||||
anchorLabel="Reflection Questions"
|
||||
html={noteMap['questions'] ?? ''}
|
||||
autoSaveMsg={autoSaveMsg}
|
||||
onChange={html => updateNote('questions', html)}
|
||||
onClose={() => setOpenAnchor(null)}
|
||||
/>
|
||||
)}
|
||||
</article>
|
||||
|
||||
{isEnrolled && !(section.checkpointQuestions && section.checkpointQuestions.length > 0) && (
|
||||
@@ -1776,13 +2016,16 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
onClick={async () => {
|
||||
if (!study || !section?.id) return
|
||||
setProgressSaving(true)
|
||||
const wasComplete = lessonCompleted
|
||||
try {
|
||||
const method = lessonCompleted ? 'DELETE' : 'POST'
|
||||
const method = wasComplete ? 'DELETE' : 'POST'
|
||||
const data = await readJson<{ ok: boolean; completedSectionIds: string[] }>(
|
||||
`/api/study-progress/${encodeURIComponent(study.slug)}/${encodeURIComponent(section.id)}`,
|
||||
{ method }
|
||||
)
|
||||
setCompletedSectionIds(Array.isArray(data.completedSectionIds) ? data.completedSectionIds : [])
|
||||
const newIds = Array.isArray(data.completedSectionIds) ? data.completedSectionIds : []
|
||||
setCompletedSectionIds(newIds)
|
||||
if (!wasComplete) checkAndShowCelebration(newIds, false)
|
||||
} catch { /* ignore */ }
|
||||
finally { setProgressSaving(false) }
|
||||
}}
|
||||
@@ -1886,12 +2129,35 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
{auth.checked && auth.authenticated && (
|
||||
<div className="study-notes-box">
|
||||
<p className="study-notes-user">Signed in as {auth.username}</p>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '0.5rem' }}>
|
||||
<button type="button" className="btn-primary" onClick={() => setNotesModalOpen(true)}>Open Notes</button>
|
||||
<Link to="/study/account" className="btn-secondary">My Account</Link>
|
||||
</div>
|
||||
<p style={{ margin: '0.5rem 0 0', color: '#b9b09b', fontSize: '0.9rem' }}>Highlight scripture, commentary, or Greek word text, right click, and add it to your notes.</p>
|
||||
{noteLoading ? <p style={{ marginTop: '0.75rem' }}>Loading notes…</p> : null}
|
||||
<Link to="/study/account" className="btn-secondary" style={{ marginBottom: '0.5rem', display: 'inline-block' }}>My Account</Link>
|
||||
{noteLoading ? (
|
||||
<p style={{ color: '#7a7060', fontSize: '0.85rem', marginTop: '0.5rem' }}>Loading notes…</p>
|
||||
) : (
|
||||
<>
|
||||
{savedNoteCount > 0 && (
|
||||
<p style={{ fontSize: '0.82rem', color: '#7a9c6a', marginBottom: '0.5rem' }}>
|
||||
{savedNoteCount} note{savedNoteCount !== 1 ? 's' : ''} saved ✓
|
||||
</p>
|
||||
)}
|
||||
<div className="note-anchor-list">
|
||||
{NOTE_ANCHORS.map(({ id, label }) => {
|
||||
const hasNote = !!(noteMap[id]?.trim())
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={`note-anchor-list-item${hasNote ? ' note-anchor-list-item--has' : ''}${openAnchor === id ? ' note-anchor-list-item--open' : ''}`}
|
||||
onClick={() => setOpenAnchor(curr => curr === id ? null : id as NoteAnchorId)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{hasNote && <span className="note-anchor-dot" aria-hidden="true">●</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p style={{ margin: '0.5rem 0 0', color: '#7a7060', fontSize: '0.82rem' }}>Click a section above to add notes. Right-click lesson text to append a selection.</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(authMessage || noteMessage || progressMessage) && <p className="study-note-status">{authMessage || noteMessage || progressMessage}</p>}
|
||||
@@ -1930,7 +2196,7 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
}}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
>
|
||||
<p style={{ margin: '0 0 0.75rem', color: '#ece3c6', fontSize: '0.95rem' }}>Add selected text to notes:</p>
|
||||
<p style={{ margin: '0 0 0.35rem', color: '#ece3c6', fontSize: '0.95rem' }}>Add to <strong style={{ color: '#c9a84c' }}>{ANCHOR_LABELS[contextNoteMenu.anchor] ?? 'Notes'}</strong>:</p>
|
||||
<div style={{ marginBottom: '0.75rem', maxHeight: '100px', overflow: 'auto', color: '#f0ead8', fontSize: '0.9rem' }}>
|
||||
{contextNoteMenu.text}
|
||||
</div>
|
||||
@@ -1938,7 +2204,7 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={() => {
|
||||
appendSelectedTextToNotes(contextNoteMenu.text)
|
||||
appendSelectedTextToNotes(contextNoteMenu.text, contextNoteMenu.anchor)
|
||||
setContextNoteMenu(null)
|
||||
}}
|
||||
>
|
||||
@@ -1947,73 +2213,6 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notesModalOpen && (
|
||||
<div
|
||||
className="study-modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Lesson notes editor"
|
||||
onClick={closeNotesModal}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
backgroundColor: 'rgba(12, 11, 10, 0.75)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 40,
|
||||
padding: '1rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="study-modal"
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
width: 'min(760px, 100%)',
|
||||
maxHeight: 'min(90vh, 900px)',
|
||||
overflowY: 'auto',
|
||||
backgroundColor: '#1b1a16',
|
||||
border: '1px solid #3c3a31',
|
||||
borderRadius: '18px',
|
||||
padding: '1.5rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0 }}>Lesson Notes</h2>
|
||||
<p style={{ margin: '0.5rem 0 0', color: '#b9b09b' }}>Save notes for this lesson and paste selections from the page.</p>
|
||||
</div>
|
||||
<button type="button" className="btn-secondary" onClick={closeNotesModal}>Close</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
rows={12}
|
||||
value={noteText}
|
||||
onChange={e => setNoteText(e.target.value)}
|
||||
placeholder="Write your notes here..."
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '280px',
|
||||
padding: '1rem',
|
||||
borderRadius: '16px',
|
||||
border: '1px solid #3f3b2f',
|
||||
background: '#14130f',
|
||||
color: '#f0ead8',
|
||||
fontSize: '1rem',
|
||||
lineHeight: '1.6',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', justifyContent: 'flex-end', marginTop: '1rem' }}>
|
||||
<button type="button" className="btn-primary" onClick={saveNote} disabled={!canSaveNote}>
|
||||
{noteSaving ? 'Saving...' : 'Save Notes'}
|
||||
</button>
|
||||
<button type="button" className="btn-secondary" onClick={closeNotesModal}>Cancel</button>
|
||||
</div>
|
||||
{(noteMessage || progressMessage) && <p className="study-note-status" style={{ marginTop: '1rem' }}>{noteMessage || progressMessage}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
@@ -2189,8 +2388,19 @@ export function ColossiansStudyNotesPage({ content }: Props) {
|
||||
<p className="study-module-lesson">{active.section ? `Lesson ${getLessonNumber(study.sections, active.section.id)}` : 'Saved Note'}</p>
|
||||
<h3>{active.section?.title ?? active.sectionId}</h3>
|
||||
<p className="study-detail-reference">{active.section ? active.section.reference : 'Lesson reference unavailable'}</p>
|
||||
<p className="study-module-focus">Your Note</p>
|
||||
<p className="study-detail-copy study-detail-copy--scripture">{active.note}</p>
|
||||
{(() => {
|
||||
const anchorMap = parseNoteMap(active.note)
|
||||
const hasContent = Object.values(anchorMap).some(v => v?.trim())
|
||||
if (!hasContent) return <p className="study-detail-copy">No notes saved for this lesson yet.</p>
|
||||
return Object.entries(anchorMap).map(([key, html]) => (
|
||||
html?.trim() ? (
|
||||
<div key={key} style={{ marginBottom: '1.25rem' }}>
|
||||
<p className="study-module-focus" style={{ marginBottom: '0.5rem' }}>{ANCHOR_LABELS[key] ?? key}</p>
|
||||
<div className="study-note-rich-content" dangerouslySetInnerHTML={{ __html: html }} />
|
||||
</div>
|
||||
) : null
|
||||
))
|
||||
})()}
|
||||
{active.section && <Link to={`/study/${study.slug}/${active.section.id}`} className="btn-secondary">Open Lesson</Link>}
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useState, useEffect } from 'react'
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
|
||||
const EMAIL_REGEX = /^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/
|
||||
|
||||
interface ContactFields {
|
||||
firstName: string
|
||||
lastName: string
|
||||
@@ -37,9 +39,23 @@ export default function ContactForm() {
|
||||
const [honey, setHoney] = useState('')
|
||||
const [status, setStatus] = useState<'idle' | 'submitting' | 'error'>('idle')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [fieldErrors, setFieldErrors] = useState<{ email?: string }>({})
|
||||
|
||||
function handleChange(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
|
||||
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
|
||||
const { name, value } = e.target
|
||||
setFields(f => ({ ...f, [name]: value }))
|
||||
if (name === 'email' && fieldErrors.email) {
|
||||
setFieldErrors(prev => ({ ...prev, email: undefined }))
|
||||
}
|
||||
}
|
||||
|
||||
function handleEmailBlur() {
|
||||
if (!fields.email) return
|
||||
if (!EMAIL_REGEX.test(fields.email)) {
|
||||
setFieldErrors(prev => ({ ...prev, email: 'Please enter a valid email address.' }))
|
||||
} else {
|
||||
setFieldErrors(prev => ({ ...prev, email: undefined }))
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectChange(e: ChangeEvent<HTMLSelectElement>) {
|
||||
@@ -93,7 +109,17 @@ export default function ContactForm() {
|
||||
</label>
|
||||
<label>
|
||||
Email
|
||||
<input type="email" name="email" required autoComplete="email" value={fields.email} onChange={handleChange} />
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
value={fields.email}
|
||||
onChange={handleChange}
|
||||
onBlur={handleEmailBlur}
|
||||
aria-describedby={fieldErrors.email ? 'contact-email-error' : undefined}
|
||||
/>
|
||||
{fieldErrors.email && <span id="contact-email-error" className="study-signup-field-error">{fieldErrors.email}</span>}
|
||||
</label>
|
||||
<label>
|
||||
Message Type
|
||||
|
||||
@@ -34,6 +34,7 @@ function SpotifyLinkIcon({ href }: { href: string }) {
|
||||
export function EpisodeAudioPlayer({ src, title, size = 'full', spotifyUrl }: EpisodeAudioPlayerProps) {
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [buffering, setBuffering] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const playTrackedRef = useRef(false)
|
||||
@@ -58,18 +59,25 @@ export function EpisodeAudioPlayer({ src, title, size = 'full', spotifyUrl }: Ep
|
||||
const onDurationChange = () => setDuration(audio.duration)
|
||||
const onEnded = () => {
|
||||
setPlaying(false)
|
||||
setBuffering(false)
|
||||
flushListenTime()
|
||||
if (title) sendEvent('audio_completion', { title })
|
||||
}
|
||||
const onWaiting = () => setBuffering(true)
|
||||
const onCanPlay = () => setBuffering(false)
|
||||
audio.addEventListener('timeupdate', onTimeUpdate)
|
||||
audio.addEventListener('durationchange', onDurationChange)
|
||||
audio.addEventListener('loadedmetadata', onDurationChange)
|
||||
audio.addEventListener('ended', onEnded)
|
||||
audio.addEventListener('waiting', onWaiting)
|
||||
audio.addEventListener('canplay', onCanPlay)
|
||||
return () => {
|
||||
audio.removeEventListener('timeupdate', onTimeUpdate)
|
||||
audio.removeEventListener('durationchange', onDurationChange)
|
||||
audio.removeEventListener('loadedmetadata', onDurationChange)
|
||||
audio.removeEventListener('ended', onEnded)
|
||||
audio.removeEventListener('waiting', onWaiting)
|
||||
audio.removeEventListener('canplay', onCanPlay)
|
||||
flushListenTime()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -135,7 +143,7 @@ export function EpisodeAudioPlayer({ src, title, size = 'full', spotifyUrl }: Ep
|
||||
|
||||
<div className="episode-audio-player__controls">
|
||||
<button
|
||||
className="episode-audio-player__play"
|
||||
className={`episode-audio-player__play${buffering ? ' episode-audio-player__play--buffering' : ''}`}
|
||||
onClick={togglePlay}
|
||||
aria-label={playing ? 'Pause' : 'Play'}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useEditor, EditorContent } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
interface NoteCardProps {
|
||||
anchorLabel: string
|
||||
html: string
|
||||
autoSaveMsg?: string
|
||||
onChange: (html: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function NoteCard({ anchorLabel, html, autoSaveMsg, onChange, onClose }: NoteCardProps) {
|
||||
const editor = useEditor({
|
||||
extensions: [StarterKit],
|
||||
content: html || '<p></p>',
|
||||
onUpdate({ editor }) {
|
||||
const next = editor.getHTML()
|
||||
onChange(next === '<p></p>' ? '' : next)
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || editor.isDestroyed) return
|
||||
if (editor.getHTML() !== html) {
|
||||
editor.commands.setContent(html || '<p></p>', false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [html])
|
||||
|
||||
useEffect(() => () => { editor?.destroy() }, [editor])
|
||||
|
||||
if (!editor) return null
|
||||
|
||||
return (
|
||||
<div className="note-card">
|
||||
<div className="note-card-header">
|
||||
<span className="note-card-label">✎ {anchorLabel}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
{autoSaveMsg && <span className="note-card-autosave">{autoSaveMsg}</span>}
|
||||
<button type="button" className="note-card-close" onClick={onClose} aria-label="Close note">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="note-card-toolbar" role="toolbar" aria-label="Text formatting">
|
||||
<button
|
||||
type="button"
|
||||
className={`note-tool${editor.isActive('bold') ? ' note-tool--active' : ''}`}
|
||||
onMouseDown={e => { e.preventDefault(); editor.chain().focus().toggleBold().run() }}
|
||||
aria-label="Bold"
|
||||
title="Bold (Ctrl+B)"
|
||||
><strong>B</strong></button>
|
||||
<button
|
||||
type="button"
|
||||
className={`note-tool${editor.isActive('italic') ? ' note-tool--active' : ''}`}
|
||||
onMouseDown={e => { e.preventDefault(); editor.chain().focus().toggleItalic().run() }}
|
||||
aria-label="Italic"
|
||||
title="Italic (Ctrl+I)"
|
||||
><em>I</em></button>
|
||||
<button
|
||||
type="button"
|
||||
className={`note-tool${editor.isActive('bulletList') ? ' note-tool--active' : ''}`}
|
||||
onMouseDown={e => { e.preventDefault(); editor.chain().focus().toggleBulletList().run() }}
|
||||
aria-label="Bullet list"
|
||||
title="Bullet list"
|
||||
>•—</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`note-tool${editor.isActive('orderedList') ? ' note-tool--active' : ''}`}
|
||||
onMouseDown={e => { e.preventDefault(); editor.chain().focus().toggleOrderedList().run() }}
|
||||
aria-label="Numbered list"
|
||||
title="Numbered list"
|
||||
>1.</button>
|
||||
<div className="note-tool-divider" />
|
||||
<button
|
||||
type="button"
|
||||
className="note-tool"
|
||||
onMouseDown={e => { e.preventDefault(); editor.chain().focus().undo().run() }}
|
||||
disabled={!editor.can().undo()}
|
||||
aria-label="Undo"
|
||||
title="Undo (Ctrl+Z)"
|
||||
>↩</button>
|
||||
<button
|
||||
type="button"
|
||||
className="note-tool"
|
||||
onMouseDown={e => { e.preventDefault(); editor.chain().focus().redo().run() }}
|
||||
disabled={!editor.can().redo()}
|
||||
aria-label="Redo"
|
||||
title="Redo (Ctrl+Y)"
|
||||
>↪</button>
|
||||
</div>
|
||||
|
||||
<EditorContent editor={editor} className="note-card-editor" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -698,7 +698,15 @@ export default function QASection() {
|
||||
</h2>
|
||||
|
||||
{loading ? (
|
||||
<p className="qa-no-results">Loading questions...</p>
|
||||
<div className="qa-skeleton-list" aria-busy="true" aria-label="Loading questions">
|
||||
{[0, 1, 2].map(i => (
|
||||
<div key={i} className="qa-skeleton-card">
|
||||
<div className="qa-skeleton-line qa-skeleton-line--short" />
|
||||
<div className="qa-skeleton-line" />
|
||||
<div className="qa-skeleton-line qa-skeleton-line--medium" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : questions.length === 0 ? (
|
||||
<div className="qa-no-results">
|
||||
<p>No questions have been answered yet. <a href="/contact">Submit yours below!</a></p>
|
||||
|
||||
Reference in New Issue
Block a user