notes upgrade

This commit is contained in:
nmemmert
2026-06-18 13:43:17 -04:00
parent e0ed798741
commit dd43d0c3ea
9 changed files with 1591 additions and 155 deletions
+646
View File
@@ -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",
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+28 -2
View File
@@ -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
+9 -1
View File
@@ -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'}
>
+96
View File
@@ -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>
)
}
+9 -1
View File
@@ -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>