coursecode 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +322 -0
- package/THIRD_PARTY_NOTICES.md +22 -0
- package/bin/cli.js +331 -0
- package/framework/assets/logo-coursecode-black.svg +14 -0
- package/framework/assets/logo-coursecode-white.svg +14 -0
- package/framework/assets/logo-coursecode.svg +14 -0
- package/framework/css/01-base.css +160 -0
- package/framework/css/02-layout.css +499 -0
- package/framework/css/accessibility.css +834 -0
- package/framework/css/components/accordions.css +710 -0
- package/framework/css/components/assessments.css +520 -0
- package/framework/css/components/audio-player.css +570 -0
- package/framework/css/components/badges.css +80 -0
- package/framework/css/components/breadcrumbs.css +87 -0
- package/framework/css/components/buttons.css +707 -0
- package/framework/css/components/callouts.css +1280 -0
- package/framework/css/components/cards.css +475 -0
- package/framework/css/components/carousel.css +193 -0
- package/framework/css/components/checkbox-group.css +123 -0
- package/framework/css/components/checklist.css +203 -0
- package/framework/css/components/collapse.css +96 -0
- package/framework/css/components/comparison.css +33 -0
- package/framework/css/components/content-image.css +36 -0
- package/framework/css/components/document-gallery.css +425 -0
- package/framework/css/components/dropdown.css +115 -0
- package/framework/css/components/embed-frame.css +142 -0
- package/framework/css/components/engagement.css +412 -0
- package/framework/css/components/features.css +35 -0
- package/framework/css/components/flip-cards.css +253 -0
- package/framework/css/components/footer.css +353 -0
- package/framework/css/components/forms.css +294 -0
- package/framework/css/components/hero.css +216 -0
- package/framework/css/components/images.css +528 -0
- package/framework/css/components/interactive-timeline.css +274 -0
- package/framework/css/components/intro-cards.css +30 -0
- package/framework/css/components/lightbox.css +666 -0
- package/framework/css/components/loading.css +65 -0
- package/framework/css/components/modals.css +235 -0
- package/framework/css/components/notifications.css +107 -0
- package/framework/css/components/quote.css +150 -0
- package/framework/css/components/sidebar.css +684 -0
- package/framework/css/components/slide-header.css +52 -0
- package/framework/css/components/spinner.css +62 -0
- package/framework/css/components/stats.css +44 -0
- package/framework/css/components/steps.css +232 -0
- package/framework/css/components/tables.css +90 -0
- package/framework/css/components/tabs.css +347 -0
- package/framework/css/components/timeline.css +154 -0
- package/framework/css/components/toggle.css +95 -0
- package/framework/css/components/tooltip.css +226 -0
- package/framework/css/components/video-player.css +438 -0
- package/framework/css/design-tokens.css +707 -0
- package/framework/css/framework.css +86 -0
- package/framework/css/interactions/accessibility.css +75 -0
- package/framework/css/interactions/base.css +92 -0
- package/framework/css/interactions/drag-drop.css +295 -0
- package/framework/css/interactions/fill-in-the-blank.css +236 -0
- package/framework/css/interactions/hotspots.css +69 -0
- package/framework/css/interactions/index.css +45 -0
- package/framework/css/interactions/interactive-image.css +359 -0
- package/framework/css/interactions/likert.css +126 -0
- package/framework/css/interactions/matching.css +354 -0
- package/framework/css/interactions/numeric-input.css +78 -0
- package/framework/css/interactions/sequencing.css +378 -0
- package/framework/css/interactions/true-false.css +177 -0
- package/framework/css/layouts/article.css +258 -0
- package/framework/css/layouts/base.css +30 -0
- package/framework/css/layouts/canvas.css +38 -0
- package/framework/css/layouts/focused.css +236 -0
- package/framework/css/layouts/index.css +29 -0
- package/framework/css/layouts/presentation.css +191 -0
- package/framework/css/layouts/traditional.css +52 -0
- package/framework/css/responsive.css +439 -0
- package/framework/css/utilities/accessibility-utils.css +59 -0
- package/framework/css/utilities/animations.css +419 -0
- package/framework/css/utilities/borders.css +72 -0
- package/framework/css/utilities/colors.css +76 -0
- package/framework/css/utilities/container.css +46 -0
- package/framework/css/utilities/decorative.css +442 -0
- package/framework/css/utilities/display.css +257 -0
- package/framework/css/utilities/flexbox.css +80 -0
- package/framework/css/utilities/grid.css +69 -0
- package/framework/css/utilities/icons.css +534 -0
- package/framework/css/utilities/lists.css +190 -0
- package/framework/css/utilities/spacing.css +167 -0
- package/framework/css/utilities/tables.css +81 -0
- package/framework/css/utilities/typography.css +159 -0
- package/framework/css/utilities/visibility.css +117 -0
- package/framework/docs/COURSE_AUTHORING_GUIDE.md +1773 -0
- package/framework/docs/COURSE_OUTLINE_GUIDE.md +725 -0
- package/framework/docs/COURSE_OUTLINE_TEMPLATE.md +161 -0
- package/framework/docs/DATA_MODEL.md +409 -0
- package/framework/docs/FRAMEWORK_GUIDE.md +1088 -0
- package/framework/docs/USER_GUIDE.md +583 -0
- package/framework/docs/examples/cloudflare-channel-relay.js +169 -0
- package/framework/docs/examples/cloudflare-data-worker.js +102 -0
- package/framework/docs/examples/cloudflare-error-worker.js +228 -0
- package/framework/index.html +175 -0
- package/framework/js/app/AppActions.js +410 -0
- package/framework/js/app/AppState.js +225 -0
- package/framework/js/app/AppUI.js +616 -0
- package/framework/js/assessment/AssessmentActions.js +615 -0
- package/framework/js/assessment/AssessmentFactory.js +471 -0
- package/framework/js/assessment/AssessmentState.js +322 -0
- package/framework/js/assessment/AssessmentUI.js +451 -0
- package/framework/js/automation/api-engagement.js +196 -0
- package/framework/js/automation/api-interactions.js +167 -0
- package/framework/js/automation/api.js +242 -0
- package/framework/js/automation/index.js +41 -0
- package/framework/js/components/interactions/drag-drop.js +884 -0
- package/framework/js/components/interactions/fill-in.js +535 -0
- package/framework/js/components/interactions/hotspot.js +702 -0
- package/framework/js/components/interactions/interaction-base.js +511 -0
- package/framework/js/components/interactions/likert.js +301 -0
- package/framework/js/components/interactions/matching.js +699 -0
- package/framework/js/components/interactions/multiple-choice.js +377 -0
- package/framework/js/components/interactions/numeric.js +271 -0
- package/framework/js/components/interactions/sequencing.js +423 -0
- package/framework/js/components/interactions/true-false.js +241 -0
- package/framework/js/components/ui-components/accordion.js +442 -0
- package/framework/js/components/ui-components/alert.js +88 -0
- package/framework/js/components/ui-components/audio-player.js +1193 -0
- package/framework/js/components/ui-components/callout.js +121 -0
- package/framework/js/components/ui-components/carousel.js +145 -0
- package/framework/js/components/ui-components/checkbox-group.js +87 -0
- package/framework/js/components/ui-components/checklist.js +40 -0
- package/framework/js/components/ui-components/collapse.js +114 -0
- package/framework/js/components/ui-components/comparison.js +30 -0
- package/framework/js/components/ui-components/conditional-display.js +150 -0
- package/framework/js/components/ui-components/content-image.js +41 -0
- package/framework/js/components/ui-components/dropdown.js +262 -0
- package/framework/js/components/ui-components/embed-frame.js +274 -0
- package/framework/js/components/ui-components/features.js +33 -0
- package/framework/js/components/ui-components/flip-card.js +230 -0
- package/framework/js/components/ui-components/form-validator.js +76 -0
- package/framework/js/components/ui-components/hero.js +49 -0
- package/framework/js/components/ui-components/index.js +12 -0
- package/framework/js/components/ui-components/interactive-image.js +235 -0
- package/framework/js/components/ui-components/interactive-timeline.js +285 -0
- package/framework/js/components/ui-components/intro-cards.js +35 -0
- package/framework/js/components/ui-components/lightbox.js +652 -0
- package/framework/js/components/ui-components/modal.js +386 -0
- package/framework/js/components/ui-components/notifications.js +145 -0
- package/framework/js/components/ui-components/progress.js +88 -0
- package/framework/js/components/ui-components/quote.js +41 -0
- package/framework/js/components/ui-components/stats.js +33 -0
- package/framework/js/components/ui-components/steps.js +41 -0
- package/framework/js/components/ui-components/tabs.js +255 -0
- package/framework/js/components/ui-components/timeline.js +42 -0
- package/framework/js/components/ui-components/toggle-group.js +73 -0
- package/framework/js/components/ui-components/tooltip.js +458 -0
- package/framework/js/components/ui-components/value-display.js +133 -0
- package/framework/js/components/ui-components/video-player.js +686 -0
- package/framework/js/core/component-catalog.js +121 -0
- package/framework/js/core/event-bus.js +178 -0
- package/framework/js/core/interaction-catalog.js +149 -0
- package/framework/js/dev/runtime-linter.js +1725 -0
- package/framework/js/drivers/cmi5-driver.js +768 -0
- package/framework/js/drivers/driver-factory.js +77 -0
- package/framework/js/drivers/driver-interface.js +110 -0
- package/framework/js/drivers/http-driver-base.js +241 -0
- package/framework/js/drivers/lti-driver.js +508 -0
- package/framework/js/drivers/proxy-driver.js +444 -0
- package/framework/js/drivers/scorm-12-driver.js +560 -0
- package/framework/js/drivers/scorm-2004-driver.js +775 -0
- package/framework/js/drivers/scorm-driver-base.js +112 -0
- package/framework/js/engagement/engagement-manager.js +404 -0
- package/framework/js/engagement/engagement-progress.js +191 -0
- package/framework/js/engagement/engagement-trackers.js +215 -0
- package/framework/js/engagement/requirement-strategies.js +268 -0
- package/framework/js/main.js +727 -0
- package/framework/js/managers/accessibility-manager.js +499 -0
- package/framework/js/managers/assessment-manager.js +230 -0
- package/framework/js/managers/audio-manager.js +944 -0
- package/framework/js/managers/comment-manager.js +88 -0
- package/framework/js/managers/flag-manager.js +86 -0
- package/framework/js/managers/interaction-manager.js +254 -0
- package/framework/js/managers/interaction-registry.js +96 -0
- package/framework/js/managers/objective-manager.js +423 -0
- package/framework/js/managers/score-manager.js +441 -0
- package/framework/js/managers/video-manager.js +536 -0
- package/framework/js/navigation/Breadcrumbs.js +234 -0
- package/framework/js/navigation/NavigationActions.js +1132 -0
- package/framework/js/navigation/NavigationState.js +276 -0
- package/framework/js/navigation/NavigationUI.js +574 -0
- package/framework/js/navigation/document-gallery.js +357 -0
- package/framework/js/navigation/navigation-helpers.js +175 -0
- package/framework/js/navigation/navigation-validators.js +174 -0
- package/framework/js/state/index.js +8 -0
- package/framework/js/state/lms-connection.js +482 -0
- package/framework/js/state/lms-error-utils.js +58 -0
- package/framework/js/state/state-commits.js +200 -0
- package/framework/js/state/state-domains.js +86 -0
- package/framework/js/state/state-manager.js +502 -0
- package/framework/js/state/state-validation.js +311 -0
- package/framework/js/state/transaction-log.js +41 -0
- package/framework/js/state/xapi-statement-service.js +325 -0
- package/framework/js/utilities/access-control.js +99 -0
- package/framework/js/utilities/breakpoint-manager.js +315 -0
- package/framework/js/utilities/canvas-slide.js +35 -0
- package/framework/js/utilities/conditional-display.js +388 -0
- package/framework/js/utilities/course-channel.js +214 -0
- package/framework/js/utilities/course-helpers.js +420 -0
- package/framework/js/utilities/data-reporter.js +273 -0
- package/framework/js/utilities/error-reporter.js +313 -0
- package/framework/js/utilities/hotspot-helper.js +341 -0
- package/framework/js/utilities/icons.js +348 -0
- package/framework/js/utilities/logger.js +92 -0
- package/framework/js/utilities/markdown-renderer.js +45 -0
- package/framework/js/utilities/scroll-tracker.js +68 -0
- package/framework/js/utilities/ui-initializer.js +146 -0
- package/framework/js/utilities/utilities.js +293 -0
- package/framework/js/utilities/view-manager.js +227 -0
- package/framework/js/validation/html-validators.js +422 -0
- package/framework/js/validation/scorm-validators.js +438 -0
- package/framework/js/vendor/pipwerks.js +931 -0
- package/framework/scripts/generate-narration.js +629 -0
- package/framework/scripts/tts-providers/azure-provider.js +178 -0
- package/framework/scripts/tts-providers/base-provider.js +81 -0
- package/framework/scripts/tts-providers/deepgram-provider.js +135 -0
- package/framework/scripts/tts-providers/elevenlabs-provider.js +148 -0
- package/framework/scripts/tts-providers/google-provider.js +272 -0
- package/framework/scripts/tts-providers/index.js +158 -0
- package/framework/scripts/tts-providers/openai-provider.js +143 -0
- package/framework/version.json +63 -0
- package/lib/authoring-api.js +919 -0
- package/lib/build-linter.js +450 -0
- package/lib/build-packaging.js +186 -0
- package/lib/build.js +88 -0
- package/lib/cloud.js +691 -0
- package/lib/convert.js +341 -0
- package/lib/course-parser.js +936 -0
- package/lib/course-writer.js +258 -0
- package/lib/create.js +248 -0
- package/lib/css-index.js +237 -0
- package/lib/dev.js +51 -0
- package/lib/export-content.js +1246 -0
- package/lib/headless-browser.js +413 -0
- package/lib/import.js +377 -0
- package/lib/index.js +80 -0
- package/lib/info.js +79 -0
- package/lib/interaction-formatters.js +568 -0
- package/lib/manifest/cmi5-manifest.js +63 -0
- package/lib/manifest/lti-tool-config.js +53 -0
- package/lib/manifest/manifest-factory.js +99 -0
- package/lib/manifest/scorm-12-manifest.js +61 -0
- package/lib/manifest/scorm-2004-manifest.js +94 -0
- package/lib/manifest/scorm-proxy-manifest.js +104 -0
- package/lib/manifest-parser.js +96 -0
- package/lib/mcp-prompts.js +753 -0
- package/lib/mcp-server.js +316 -0
- package/lib/narration.js +53 -0
- package/lib/pdf-structure.js +142 -0
- package/lib/preview-export.js +231 -0
- package/lib/preview-routes-api.js +662 -0
- package/lib/preview-routes-editing.js +159 -0
- package/lib/preview-routes-lms.js +230 -0
- package/lib/preview-server.js +564 -0
- package/lib/project-utils.js +269 -0
- package/lib/proxy-templates/proxy.html +68 -0
- package/lib/proxy-templates/scorm-bridge.js +112 -0
- package/lib/scaffold.js +193 -0
- package/lib/schema-extractor.js +361 -0
- package/lib/slide-source-editor.js +586 -0
- package/lib/stub-player/app-viewer.js +195 -0
- package/lib/stub-player/app.js +370 -0
- package/lib/stub-player/catalog-panel.js +312 -0
- package/lib/stub-player/config-panel.js +1303 -0
- package/lib/stub-player/content-generator.js +586 -0
- package/lib/stub-player/content-viewer.js +173 -0
- package/lib/stub-player/debug-panel.js +420 -0
- package/lib/stub-player/edit-mode.js +922 -0
- package/lib/stub-player/edit-utils.js +400 -0
- package/lib/stub-player/header-bar.js +354 -0
- package/lib/stub-player/interaction-editor.js +210 -0
- package/lib/stub-player/interactions-panel.js +565 -0
- package/lib/stub-player/lms-api.js +1094 -0
- package/lib/stub-player/login-screen.js +74 -0
- package/lib/stub-player/outline-mode.js +689 -0
- package/lib/stub-player/styles/_assessments-panel.css +245 -0
- package/lib/stub-player/styles/_base.css +89 -0
- package/lib/stub-player/styles/_catalog-icons.css +96 -0
- package/lib/stub-player/styles/_catalog-panel.css +291 -0
- package/lib/stub-player/styles/_config-panel.css +636 -0
- package/lib/stub-player/styles/_content-viewer.css +834 -0
- package/lib/stub-player/styles/_debug-panel.css +576 -0
- package/lib/stub-player/styles/_edit-mode.css +128 -0
- package/lib/stub-player/styles/_header-bar.css +343 -0
- package/lib/stub-player/styles/_interaction-editor.css +140 -0
- package/lib/stub-player/styles/_interactions-panel.css +1038 -0
- package/lib/stub-player/styles/_login-screen.css +102 -0
- package/lib/stub-player/styles/_outline-mode.css +752 -0
- package/lib/stub-player/styles.css +15 -0
- package/lib/stub-player.js +160 -0
- package/lib/test-data-reporting.js +176 -0
- package/lib/test-error-reporting.js +146 -0
- package/lib/token.js +86 -0
- package/lib/upgrade.js +257 -0
- package/lib/validation-rules.js +517 -0
- package/lib/vite-plugin-content-discovery.js +296 -0
- package/package.json +108 -0
- package/schemas/XMLSchema.dtd +402 -0
- package/schemas/adlcp_v1p3.xsd +111 -0
- package/schemas/adlnav_v1p3.xsd +61 -0
- package/schemas/adlseq_v1p3.xsd +93 -0
- package/schemas/common/anyElement.xsd +27 -0
- package/schemas/common/dataTypes.xsd +138 -0
- package/schemas/common/elementNames.xsd +767 -0
- package/schemas/common/elementTypes.xsd +786 -0
- package/schemas/common/rootElement.xsd +31 -0
- package/schemas/common/vocabTypes.xsd +345 -0
- package/schemas/common/vocabValues.xsd +257 -0
- package/schemas/datatypes.dtd +203 -0
- package/schemas/ims_xml.xsd +35 -0
- package/schemas/imscp_v1p1.xsd +368 -0
- package/schemas/imsss_v1p0.xsd +67 -0
- package/schemas/imsss_v1p0auxresource.xsd +19 -0
- package/schemas/imsss_v1p0control.xsd +20 -0
- package/schemas/imsss_v1p0delivery.xsd +17 -0
- package/schemas/imsss_v1p0limit.xsd +47 -0
- package/schemas/imsss_v1p0objective.xsd +67 -0
- package/schemas/imsss_v1p0random.xsd +16 -0
- package/schemas/imsss_v1p0rollup.xsd +46 -0
- package/schemas/imsss_v1p0seqrule.xsd +108 -0
- package/schemas/imsss_v1p0util.xsd +94 -0
- package/schemas/license.txt +17 -0
- package/schemas/lom.xsd +102 -0
- package/schemas/lomCustom.xsd +62 -0
- package/schemas/lomLoose.xsd +62 -0
- package/schemas/lomStrict.xsd +62 -0
- package/schemas/xml.xsd +81 -0
- package/template/.env.example +92 -0
- package/template/course/assets/audio/example-intro.mp3 +0 -0
- package/template/course/assets/audio/example-ui-demo--compact-player.mp3 +0 -0
- package/template/course/assets/audio/example-ui-demo--demo-modal.mp3 +0 -0
- package/template/course/assets/audio/example-ui-demo--full-player.mp3 +0 -0
- package/template/course/assets/docs/example_md_1.md +39 -0
- package/template/course/assets/docs/example_md_2.md +41 -0
- package/template/course/assets/docs/example_pdf_1_thumbnail.png +0 -0
- package/template/course/assets/docs/example_pdf_2.pdf +0 -0
- package/template/course/assets/images/course-architecture.svg +36 -0
- package/template/course/assets/images/logo.svg +14 -0
- package/template/course/assets/widgets/counter-demo.html +190 -0
- package/template/course/assets/widgets/gravity-painter.html +384 -0
- package/template/course/course-config.js +539 -0
- package/template/course/icons.js +19 -0
- package/template/course/interactions/PLUGIN_GUIDE.md +97 -0
- package/template/course/slides/example-course-structure.js +138 -0
- package/template/course/slides/example-final-exam.js +144 -0
- package/template/course/slides/example-finishing.js +127 -0
- package/template/course/slides/example-interactions-showcase.js +615 -0
- package/template/course/slides/example-preview-tour.js +129 -0
- package/template/course/slides/example-remedial.js +143 -0
- package/template/course/slides/example-summary.js +103 -0
- package/template/course/slides/example-ui-showcase.js +1805 -0
- package/template/course/slides/example-welcome.js +123 -0
- package/template/course/slides/example-workflow.js +140 -0
- package/template/course/theme.css +165 -0
- package/template/eslint.config.js +47 -0
- package/template/package.json +28 -0
- package/template/vite.config.js +339 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Worker + Durable Object - Generic Pub/Sub Channel Relay
|
|
3
|
+
*
|
|
4
|
+
* Content-agnostic message fan-out. Receives JSON via POST, broadcasts to all
|
|
5
|
+
* connected SSE clients on the same channel. Does not parse or interpret messages.
|
|
6
|
+
*
|
|
7
|
+
* SETUP:
|
|
8
|
+
* 1. Create a Cloudflare account and go to Workers & Pages
|
|
9
|
+
* 2. Create a new Worker and paste this code
|
|
10
|
+
* 3. Add Durable Object binding in wrangler.toml:
|
|
11
|
+
* [[durable_objects.bindings]]
|
|
12
|
+
* name = "CHANNEL"
|
|
13
|
+
* class_name = "ChannelRelay"
|
|
14
|
+
*
|
|
15
|
+
* [[migrations]]
|
|
16
|
+
* tag = "v1"
|
|
17
|
+
* new_classes = ["ChannelRelay"]
|
|
18
|
+
* 4. Add COURSE_API_KEY secret in Settings > Variables (optional, for auth)
|
|
19
|
+
* 5. Deploy and copy your worker URL
|
|
20
|
+
* 6. Add the URL to course-config.js:
|
|
21
|
+
* environment: {
|
|
22
|
+
* channel: {
|
|
23
|
+
* endpoint: 'https://your-worker.your-subdomain.workers.dev',
|
|
24
|
+
* apiKey: 'your-shared-secret', // Must match COURSE_API_KEY secret
|
|
25
|
+
* channelId: 'my-session-123'
|
|
26
|
+
* }
|
|
27
|
+
* }
|
|
28
|
+
*
|
|
29
|
+
* ENDPOINTS:
|
|
30
|
+
* POST /:channelId — publish a JSON message to all listeners
|
|
31
|
+
* GET /:channelId — SSE stream, receive all messages on that channel
|
|
32
|
+
*
|
|
33
|
+
* FREE TIER LIMITS:
|
|
34
|
+
* - Cloudflare Workers: 100,000 requests/day
|
|
35
|
+
* - Durable Objects: 1M requests/month (paid plan required for production)
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const corsHeaders = {
|
|
39
|
+
'Access-Control-Allow-Origin': '*',
|
|
40
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
41
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export default {
|
|
45
|
+
async fetch(request, env) {
|
|
46
|
+
if (request.method === 'OPTIONS') {
|
|
47
|
+
return new Response(null, { headers: corsHeaders });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Extract channelId from URL path
|
|
51
|
+
const url = new URL(request.url);
|
|
52
|
+
const channelId = url.pathname.replace(/^\/+/, '');
|
|
53
|
+
|
|
54
|
+
if (!channelId) {
|
|
55
|
+
return new Response('Missing channelId in URL path', {
|
|
56
|
+
status: 400,
|
|
57
|
+
headers: corsHeaders
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Validate API key if configured
|
|
62
|
+
if (env.COURSE_API_KEY) {
|
|
63
|
+
// POST uses Authorization header; SSE GET uses ?token= param (EventSource can't send headers)
|
|
64
|
+
const auth = request.method === 'GET'
|
|
65
|
+
? url.searchParams.get('token')
|
|
66
|
+
: request.headers.get('Authorization')?.replace('Bearer ', '');
|
|
67
|
+
if (!auth || auth !== env.COURSE_API_KEY) {
|
|
68
|
+
return new Response('Unauthorized', { status: 401, headers: corsHeaders });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Route to Durable Object for this channel
|
|
73
|
+
const id = env.CHANNEL.idFromName(channelId);
|
|
74
|
+
const stub = env.CHANNEL.get(id);
|
|
75
|
+
return stub.fetch(request);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Durable Object — one instance per channelId
|
|
81
|
+
* Holds connected SSE clients and fans out messages.
|
|
82
|
+
*/
|
|
83
|
+
export class ChannelRelay {
|
|
84
|
+
constructor() {
|
|
85
|
+
this.clients = new Set();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async fetch(request) {
|
|
89
|
+
if (request.method === 'GET') {
|
|
90
|
+
return this.handleSSE();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (request.method === 'POST') {
|
|
94
|
+
return this.handlePublish(request);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return new Response('Method not allowed', {
|
|
98
|
+
status: 405,
|
|
99
|
+
headers: corsHeaders
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* SSE connection — client listens for messages
|
|
105
|
+
*/
|
|
106
|
+
handleSSE() {
|
|
107
|
+
const { readable, writable } = new TransformStream();
|
|
108
|
+
const writer = writable.getWriter();
|
|
109
|
+
const encoder = new TextEncoder();
|
|
110
|
+
|
|
111
|
+
// Track this client
|
|
112
|
+
const client = { writer, encoder };
|
|
113
|
+
this.clients.add(client);
|
|
114
|
+
|
|
115
|
+
// Send keepalive comment every 30s to prevent timeout
|
|
116
|
+
const keepalive = setInterval(() => {
|
|
117
|
+
writer.write(encoder.encode(': keepalive\n\n')).catch(() => {
|
|
118
|
+
clearInterval(keepalive);
|
|
119
|
+
this.clients.delete(client);
|
|
120
|
+
});
|
|
121
|
+
}, 30000);
|
|
122
|
+
|
|
123
|
+
// Clean up when client disconnects
|
|
124
|
+
request?.signal?.addEventListener?.('abort', () => {
|
|
125
|
+
clearInterval(keepalive);
|
|
126
|
+
this.clients.delete(client);
|
|
127
|
+
writer.close().catch(() => {});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return new Response(readable, {
|
|
131
|
+
status: 200,
|
|
132
|
+
headers: {
|
|
133
|
+
'Content-Type': 'text/event-stream',
|
|
134
|
+
'Cache-Control': 'no-cache',
|
|
135
|
+
'Connection': 'keep-alive',
|
|
136
|
+
...corsHeaders
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Publish — fan out message to all connected SSE clients
|
|
143
|
+
*/
|
|
144
|
+
async handlePublish(request) {
|
|
145
|
+
const data = await request.text();
|
|
146
|
+
|
|
147
|
+
// Fan out to all connected clients
|
|
148
|
+
const message = `data: ${data}\n\n`;
|
|
149
|
+
const dead = [];
|
|
150
|
+
|
|
151
|
+
for (const client of this.clients) {
|
|
152
|
+
try {
|
|
153
|
+
await client.writer.write(client.encoder.encode(message));
|
|
154
|
+
} catch {
|
|
155
|
+
dead.push(client);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Clean up disconnected clients
|
|
160
|
+
for (const client of dead) {
|
|
161
|
+
this.clients.delete(client);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return new Response(JSON.stringify({ success: true, listeners: this.clients.size }), {
|
|
165
|
+
status: 200,
|
|
166
|
+
headers: { 'Content-Type': 'application/json', ...corsHeaders }
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Worker - Course Data Reporting Endpoint
|
|
3
|
+
*
|
|
4
|
+
* Receives batched learning records (assessments, objectives, interactions)
|
|
5
|
+
* from courses and stores them for analytics or forwards to your data warehouse.
|
|
6
|
+
*
|
|
7
|
+
* SETUP:
|
|
8
|
+
* 1. Create a Cloudflare account and go to Workers & Pages
|
|
9
|
+
* 2. Create a new Worker and paste this code
|
|
10
|
+
* 3. (Optional) Add KV namespace for storage, or forward to your API
|
|
11
|
+
* 4. Add COURSE_API_KEY secret in Settings > Variables (optional, for auth)
|
|
12
|
+
* 5. Deploy and copy your worker URL
|
|
13
|
+
* 6. Add the URL to course-config.js:
|
|
14
|
+
* environment: {
|
|
15
|
+
* dataReporting: {
|
|
16
|
+
* endpoint: 'https://your-worker.your-subdomain.workers.dev',
|
|
17
|
+
* apiKey: 'your-shared-secret', // Must match COURSE_API_KEY secret
|
|
18
|
+
* batchSize: 10,
|
|
19
|
+
* flushInterval: 30000
|
|
20
|
+
* }
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* PAYLOAD FORMAT:
|
|
24
|
+
* {
|
|
25
|
+
* records: [
|
|
26
|
+
* { type: 'assessment', data: { assessmentId, score, passed, ... }, timestamp },
|
|
27
|
+
* { type: 'objective', data: { objectiveId, completion_status, ... }, timestamp },
|
|
28
|
+
* { type: 'interaction', data: { interactionId, type, result, ... }, timestamp }
|
|
29
|
+
* ],
|
|
30
|
+
* course: { title, version, id },
|
|
31
|
+
* sentAt: '2024-01-15T10:30:00.000Z'
|
|
32
|
+
* }
|
|
33
|
+
*
|
|
34
|
+
* FREE TIER LIMITS:
|
|
35
|
+
* - Cloudflare Workers: 100,000 requests/day
|
|
36
|
+
* - KV Storage: 100,000 reads/day, 1,000 writes/day
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
export default {
|
|
40
|
+
async fetch(request, env) {
|
|
41
|
+
const corsHeaders = {
|
|
42
|
+
'Access-Control-Allow-Origin': '*',
|
|
43
|
+
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
|
44
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
if (request.method === 'OPTIONS') {
|
|
48
|
+
return new Response(null, { headers: corsHeaders });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (request.method !== 'POST') {
|
|
52
|
+
return new Response('Method not allowed', {
|
|
53
|
+
status: 405,
|
|
54
|
+
headers: corsHeaders
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Validate API key if configured
|
|
59
|
+
if (env.COURSE_API_KEY) {
|
|
60
|
+
const auth = request.headers.get('Authorization');
|
|
61
|
+
if (!auth || auth !== `Bearer ${env.COURSE_API_KEY}`) {
|
|
62
|
+
return new Response('Unauthorized', { status: 401, headers: corsHeaders });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const data = await request.json();
|
|
68
|
+
|
|
69
|
+
// Log for debugging (visible in Cloudflare dashboard)
|
|
70
|
+
console.warn(`[Data Worker] Received ${data.records?.length || 0} records from ${data.course?.title || 'Unknown Course'}`);
|
|
71
|
+
|
|
72
|
+
// Option 1: Store in KV (simple, built-in)
|
|
73
|
+
// Uncomment and bind a KV namespace called DATA_STORE
|
|
74
|
+
// const key = `${data.course?.id || 'unknown'}_${Date.now()}`;
|
|
75
|
+
// await env.DATA_STORE.put(key, JSON.stringify(data), { expirationTtl: 86400 * 30 });
|
|
76
|
+
|
|
77
|
+
// Option 2: Forward to your API/webhook
|
|
78
|
+
// await fetch('https://your-api.com/learning-data', {
|
|
79
|
+
// method: 'POST',
|
|
80
|
+
// headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${env.API_KEY}` },
|
|
81
|
+
// body: JSON.stringify(data)
|
|
82
|
+
// });
|
|
83
|
+
|
|
84
|
+
// Option 3: Insert into D1 database (Cloudflare's SQLite)
|
|
85
|
+
// await env.DB.prepare('INSERT INTO records (course_id, data, created_at) VALUES (?, ?, ?)')
|
|
86
|
+
// .bind(data.course?.id, JSON.stringify(data.records), data.sentAt)
|
|
87
|
+
// .run();
|
|
88
|
+
|
|
89
|
+
return new Response(JSON.stringify({ success: true, count: data.records?.length }), {
|
|
90
|
+
status: 200,
|
|
91
|
+
headers: { 'Content-Type': 'application/json', ...corsHeaders }
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
} catch (error) {
|
|
95
|
+
console.error('Worker error:', error);
|
|
96
|
+
return new Response('Internal error', {
|
|
97
|
+
status: 500,
|
|
98
|
+
headers: corsHeaders
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
};
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Worker - Course Error & Issue Report Email Notifications
|
|
3
|
+
*
|
|
4
|
+
* Deploy this worker to receive error reports and user issue reports from courses
|
|
5
|
+
* and send email alerts. The API key stays server-side, so it's never exposed in the course.
|
|
6
|
+
*
|
|
7
|
+
* SETUP:
|
|
8
|
+
* 1. Create a Cloudflare account and go to Workers & Pages
|
|
9
|
+
* 2. Create a new Worker and paste this code
|
|
10
|
+
* 3. Add these secrets in Settings > Variables:
|
|
11
|
+
* - RESEND_API_KEY: Your Resend API key
|
|
12
|
+
* - ALERT_EMAIL: Email address to receive alerts
|
|
13
|
+
* - FROM_EMAIL: Verified sender email (e.g., errors@yourdomain.com)
|
|
14
|
+
* - COURSE_API_KEY: Shared secret for authenticating course requests (optional)
|
|
15
|
+
* 4. Deploy and copy your worker URL
|
|
16
|
+
* 5. Add the URL to course-config.js:
|
|
17
|
+
* environment: {
|
|
18
|
+
* errorReporting: {
|
|
19
|
+
* endpoint: 'https://your-worker.your-subdomain.workers.dev',
|
|
20
|
+
* apiKey: 'your-shared-secret', // Must match COURSE_API_KEY secret
|
|
21
|
+
* enableUserReports: true
|
|
22
|
+
* }
|
|
23
|
+
* }
|
|
24
|
+
*
|
|
25
|
+
* FREE TIER LIMITS:
|
|
26
|
+
* - Cloudflare Workers: 100,000 requests/day
|
|
27
|
+
* - Resend: 3,000 emails/month
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export default {
|
|
31
|
+
async fetch(request, env) {
|
|
32
|
+
// CORS headers for cross-origin requests from courses
|
|
33
|
+
const corsHeaders = {
|
|
34
|
+
'Access-Control-Allow-Origin': '*',
|
|
35
|
+
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
|
36
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Handle CORS preflight (must be checked BEFORE POST check)
|
|
40
|
+
if (request.method === 'OPTIONS') {
|
|
41
|
+
return new Response(null, { headers: corsHeaders });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Only accept POST requests
|
|
45
|
+
if (request.method !== 'POST') {
|
|
46
|
+
return new Response('Method not allowed', {
|
|
47
|
+
status: 405,
|
|
48
|
+
headers: corsHeaders
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Validate API key if configured
|
|
53
|
+
if (env.COURSE_API_KEY) {
|
|
54
|
+
const auth = request.headers.get('Authorization');
|
|
55
|
+
if (!auth || auth !== `Bearer ${env.COURSE_API_KEY}`) {
|
|
56
|
+
return new Response('Unauthorized', { status: 401, headers: corsHeaders });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const data = await request.json();
|
|
62
|
+
|
|
63
|
+
// Determine if this is a user report or automatic error
|
|
64
|
+
const isUserReport = data.type === 'user_report';
|
|
65
|
+
|
|
66
|
+
// Build email content
|
|
67
|
+
const subject = isUserReport
|
|
68
|
+
? `[User Report] Issue reported in ${data.course?.title || 'Course'}`
|
|
69
|
+
: `[Course Error] ${data.domain}: ${data.operation}`;
|
|
70
|
+
const text = isUserReport
|
|
71
|
+
? formatUserReportEmail(data)
|
|
72
|
+
: formatErrorEmail(data);
|
|
73
|
+
|
|
74
|
+
// Send via Resend
|
|
75
|
+
const emailResponse = await fetch('https://api.resend.com/emails', {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: {
|
|
78
|
+
'Authorization': `Bearer ${env.RESEND_API_KEY}`,
|
|
79
|
+
'Content-Type': 'application/json'
|
|
80
|
+
},
|
|
81
|
+
body: JSON.stringify({
|
|
82
|
+
from: env.FROM_EMAIL || 'Course Errors <errors@yourdomain.com>',
|
|
83
|
+
to: env.ALERT_EMAIL,
|
|
84
|
+
subject: subject,
|
|
85
|
+
text: text
|
|
86
|
+
})
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
if (!emailResponse.ok) {
|
|
90
|
+
const errorText = await emailResponse.text();
|
|
91
|
+
console.error('Resend error:', errorText);
|
|
92
|
+
return new Response('Failed to send email', {
|
|
93
|
+
status: 500,
|
|
94
|
+
headers: corsHeaders
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return new Response(JSON.stringify({ success: true }), {
|
|
99
|
+
status: 200,
|
|
100
|
+
headers: {
|
|
101
|
+
'Content-Type': 'application/json',
|
|
102
|
+
...corsHeaders
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
} catch (error) {
|
|
107
|
+
console.error('Worker error:', error);
|
|
108
|
+
return new Response('Internal error', {
|
|
109
|
+
status: 500,
|
|
110
|
+
headers: corsHeaders
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Format user-submitted issue report into email
|
|
118
|
+
*/
|
|
119
|
+
function formatUserReportEmail(data) {
|
|
120
|
+
const lines = [
|
|
121
|
+
'=== USER ISSUE REPORT ===',
|
|
122
|
+
'',
|
|
123
|
+
`Time: ${data.timestamp}`,
|
|
124
|
+
''
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
if (data.course) {
|
|
128
|
+
lines.push(
|
|
129
|
+
'--- Course ---',
|
|
130
|
+
`Title: ${data.course.title || 'N/A'}`,
|
|
131
|
+
`Version: ${data.course.version || 'N/A'}`,
|
|
132
|
+
''
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (data.currentSlide) {
|
|
137
|
+
lines.push(
|
|
138
|
+
'--- Location ---',
|
|
139
|
+
`Current Slide: ${data.currentSlide}`,
|
|
140
|
+
''
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
lines.push(
|
|
145
|
+
'--- User Description ---',
|
|
146
|
+
data.description || '(No description provided)',
|
|
147
|
+
'',
|
|
148
|
+
'--- Environment ---',
|
|
149
|
+
`URL: ${data.url}`,
|
|
150
|
+
`User Agent: ${data.userAgent}`,
|
|
151
|
+
''
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
return lines.join('\n');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Format error data into a readable email.
|
|
159
|
+
* Handles both single errors and batched errors (data.errors array).
|
|
160
|
+
*/
|
|
161
|
+
function formatErrorEmail(data) {
|
|
162
|
+
const lines = [
|
|
163
|
+
'=== COURSE ERROR REPORT ===',
|
|
164
|
+
'',
|
|
165
|
+
`Time: ${data.timestamp}`,
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
// Batched errors: the payload contains an `errors` array
|
|
169
|
+
if (Array.isArray(data.errors) && data.errors.length > 1) {
|
|
170
|
+
lines.push(`Errors: ${data.errors.length}`, '');
|
|
171
|
+
for (let i = 0; i < data.errors.length; i++) {
|
|
172
|
+
const err = data.errors[i];
|
|
173
|
+
lines.push(
|
|
174
|
+
`--- Error ${i + 1} ---`,
|
|
175
|
+
`Domain: ${err.domain || 'unknown'}`,
|
|
176
|
+
`Operation: ${err.operation || 'unknown'}`,
|
|
177
|
+
err.message || '(no message)',
|
|
178
|
+
''
|
|
179
|
+
);
|
|
180
|
+
if (err.stack) lines.push('Stack:', err.stack, '');
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
// Single error (backward-compatible flat format)
|
|
184
|
+
lines.push(
|
|
185
|
+
`Domain: ${data.domain}`,
|
|
186
|
+
`Operation: ${data.operation}`,
|
|
187
|
+
'',
|
|
188
|
+
'--- Message ---',
|
|
189
|
+
data.message,
|
|
190
|
+
''
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
if (data.stack) {
|
|
194
|
+
lines.push(
|
|
195
|
+
'--- Stack Trace ---',
|
|
196
|
+
data.stack,
|
|
197
|
+
''
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (data.context) {
|
|
202
|
+
lines.push(
|
|
203
|
+
'--- Context ---',
|
|
204
|
+
JSON.stringify(data.context, null, 2),
|
|
205
|
+
''
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (data.course) {
|
|
211
|
+
lines.push(
|
|
212
|
+
'--- Course ---',
|
|
213
|
+
`Title: ${data.course.title || 'N/A'}`,
|
|
214
|
+
`Version: ${data.course.version || 'N/A'}`,
|
|
215
|
+
`ID: ${data.course.id || 'N/A'}`,
|
|
216
|
+
''
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
lines.push(
|
|
221
|
+
'--- Environment ---',
|
|
222
|
+
`URL: ${data.url}`,
|
|
223
|
+
`User Agent: ${data.userAgent}`,
|
|
224
|
+
''
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
return lines.join('\n');
|
|
228
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="UTF-8" />
|
|
6
|
+
<title id="page-title">Loading...</title>
|
|
7
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
8
|
+
<meta name="description" id="page-description" content="" />
|
|
9
|
+
<link rel="stylesheet" href="css/framework.css" />
|
|
10
|
+
<link rel="stylesheet" href="../course/theme.css" />
|
|
11
|
+
<!-- Skip link is styled via framework.css for accessibility -->
|
|
12
|
+
</head>
|
|
13
|
+
|
|
14
|
+
<body>
|
|
15
|
+
<!-- Accessibility: Skip to main content link -->
|
|
16
|
+
<a href="#content" class="skip-link">Skip to main content</a>
|
|
17
|
+
|
|
18
|
+
<div id="app" role="application" aria-label="SCORM Learning Module">
|
|
19
|
+
<!-- ========== LIVE REGIONS FOR SCREEN READERS ========== -->
|
|
20
|
+
<!-- Progress/status announcements for screen readers -->
|
|
21
|
+
<div id="progress-announce" aria-live="polite" aria-atomic="true" class="sr-only"></div>
|
|
22
|
+
|
|
23
|
+
<!-- ========== HEADER ========== -->
|
|
24
|
+
<header class="course-header" role="banner">
|
|
25
|
+
<!-- Navigation Menu Toggle -->
|
|
26
|
+
<button id="sidebar-toggle" class="sidebar-toggle" aria-label="Toggle navigation menu" aria-expanded="false"
|
|
27
|
+
aria-controls="sidebar" data-testid="nav-menu-toggle" data-tooltip="Toggle navigation menu"
|
|
28
|
+
data-tooltip-position="right">
|
|
29
|
+
<span class="toggle-icon" aria-hidden="true"><!-- Icon --></span>
|
|
30
|
+
</button>
|
|
31
|
+
|
|
32
|
+
<!-- Branding -->
|
|
33
|
+
<div id="brand" aria-label="Course brand">
|
|
34
|
+
<!-- Dynamically populated from course-config.js -->
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<!-- Course Progress Indicator -->
|
|
38
|
+
<div id="header-progress" class="header-progress" hidden aria-label="Course progress">
|
|
39
|
+
<span class="header-progress-text">Slide 1 of 1</span>
|
|
40
|
+
<div class="header-progress-bar" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
|
|
41
|
+
<div class="header-progress-fill"></div>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<!-- Accessibility Options Menu -->
|
|
46
|
+
<button id="accessibility-button" class="sidebar-toggle" aria-label="Accessibility options" aria-haspopup="true"
|
|
47
|
+
aria-expanded="false" data-tooltip="Accessibility settings" data-tooltip-position="left">
|
|
48
|
+
<span aria-hidden="true"><!-- Icon --></span>
|
|
49
|
+
</button>
|
|
50
|
+
|
|
51
|
+
<!-- Accessibility Menu Popup -->
|
|
52
|
+
<div id="accessibility-menu" class="popup-menu" role="menu" aria-label="Settings" hidden>
|
|
53
|
+
<button id="theme-toggle" role="menuitem" aria-label="Toggle dark mode">
|
|
54
|
+
<span aria-hidden="true"><!-- Icon --></span>
|
|
55
|
+
<span class="menu-label">Dark Mode</span>
|
|
56
|
+
</button>
|
|
57
|
+
<button id="font-size-toggle" role="menuitem" aria-label="Increase font size">
|
|
58
|
+
<span aria-hidden="true">A+</span>
|
|
59
|
+
<span class="menu-label">Font Size</span>
|
|
60
|
+
</button>
|
|
61
|
+
<button id="high-contrast-toggle" role="menuitem" aria-label="Toggle high contrast">
|
|
62
|
+
<span aria-hidden="true"><!-- Icon --></span>
|
|
63
|
+
<span class="menu-label">High Contrast</span>
|
|
64
|
+
</button>
|
|
65
|
+
<button id="reduced-motion-toggle" role="menuitem" aria-label="Toggle reduced motion">
|
|
66
|
+
<span aria-hidden="true"><!-- Icon --></span>
|
|
67
|
+
<span class="menu-label">Reduced Motion</span>
|
|
68
|
+
</button>
|
|
69
|
+
<hr class="menu-divider" id="report-issue-divider" hidden />
|
|
70
|
+
<button id="report-issue-button" role="menuitem" aria-label="Report an issue" hidden>
|
|
71
|
+
<span aria-hidden="true"><!-- Icon --></span>
|
|
72
|
+
<span class="menu-label">Report Issue</span>
|
|
73
|
+
</button>
|
|
74
|
+
<hr class="menu-divider exit-divider" />
|
|
75
|
+
<button id="menu-exit-button" role="menuitem" aria-label="Exit course" data-action="exit-course">
|
|
76
|
+
<span aria-hidden="true"><!-- Icon --></span>
|
|
77
|
+
<span class="menu-label">Exit Course</span>
|
|
78
|
+
</button>
|
|
79
|
+
</div>
|
|
80
|
+
</header>
|
|
81
|
+
|
|
82
|
+
<!-- ========== MAIN CONTENT AREA ========== -->
|
|
83
|
+
<div class="app-body">
|
|
84
|
+
<!-- Sidebar Backdrop (for mobile overlay) -->
|
|
85
|
+
<div id="sidebar-backdrop" class="sidebar-backdrop" aria-hidden="true"></div>
|
|
86
|
+
|
|
87
|
+
<!-- Navigation Sidebar -->
|
|
88
|
+
<aside id="sidebar" class="sidebar collapsed" aria-label="Course navigation sidebar">
|
|
89
|
+
<nav id="menu" aria-label="Course navigation menu" role="navigation">
|
|
90
|
+
<!-- Dynamically populated from course-config.js -->
|
|
91
|
+
<div id="menu-help" class="sr-only">Use these buttons to navigate between course sections</div>
|
|
92
|
+
</nav>
|
|
93
|
+
<!-- Document Gallery (populated by document-gallery.js when enabled) -->
|
|
94
|
+
<div id="sidebar-gallery" class="sidebar-gallery" hidden aria-label="Course resources"></div>
|
|
95
|
+
</aside>
|
|
96
|
+
|
|
97
|
+
<!-- Main Content -->
|
|
98
|
+
<main id="content" tabindex="-1" role="main" aria-live="polite" aria-atomic="false">
|
|
99
|
+
<!-- Loading indicator -->
|
|
100
|
+
<div id="loading" aria-hidden="true">
|
|
101
|
+
<div class="spinner" role="status" aria-label="Loading"></div>
|
|
102
|
+
<span class="loading-text">Loading course content...</span>
|
|
103
|
+
</div>
|
|
104
|
+
<!-- Slide content injected here -->
|
|
105
|
+
<div id="slide-container"></div>
|
|
106
|
+
</main>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<!-- ========== FOOTER / NAVIGATION CONTROLS ========== -->
|
|
110
|
+
<footer class="app-footer" role="contentinfo">
|
|
111
|
+
<div class="nav-controls" role="toolbar" aria-label="Navigation controls">
|
|
112
|
+
<div class="nav-nav-buttons">
|
|
113
|
+
<!-- Previous/Next Navigation -->
|
|
114
|
+
<button id="prevBtn" class="btn btn-secondary" aria-describedby="nav-help" aria-keyshortcuts="Alt+Left"
|
|
115
|
+
data-action="nav-prev" data-testid="nav-prev" data-tooltip="Previous"><span
|
|
116
|
+
class="btn-text">Previous</span></button>
|
|
117
|
+
<button id="nextBtn" class="btn btn-secondary" aria-describedby="nav-help" aria-keyshortcuts="Alt+Right"
|
|
118
|
+
data-action="nav-next" data-testid="nav-next" data-tooltip="Next"><span
|
|
119
|
+
class="btn-text">Next</span></button>
|
|
120
|
+
<!-- Engagement Progress Indicator -->
|
|
121
|
+
<div id="engagement-indicator" class="engagement-indicator-footer" hidden aria-live="polite"
|
|
122
|
+
data-tooltip="Slide Progress: 0%" data-testid="engagement-indicator">
|
|
123
|
+
<svg class="engagement-circle" width="32" height="32" viewBox="0 0 32 32" aria-hidden="true">
|
|
124
|
+
<!-- Invisible hit area for tooltip - covers entire circle -->
|
|
125
|
+
<circle class="engagement-hit-area" cx="16" cy="16" r="16" fill="transparent"></circle>
|
|
126
|
+
<circle class="engagement-track" cx="16" cy="16" r="14" fill="none" stroke-width="3"></circle>
|
|
127
|
+
<circle class="engagement-progress" cx="16" cy="16" r="14" fill="none" stroke-width="3"
|
|
128
|
+
stroke-linecap="round"></circle>
|
|
129
|
+
<text class="engagement-checkmark" x="16" y="16" text-anchor="middle" dominant-baseline="central"
|
|
130
|
+
font-size="16">✓</text>
|
|
131
|
+
</svg>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
<!-- Audio Player (hidden by default, shown when narration is active) -->
|
|
135
|
+
<div id="audio-player" hidden aria-label="Audio narration player" data-testid="audio-player">
|
|
136
|
+
<!-- Controls rendered by audio-player.js -->
|
|
137
|
+
</div>
|
|
138
|
+
<!-- Exit Course Button -->
|
|
139
|
+
<div class="nav-exit-button">
|
|
140
|
+
<button id="exitBtn" class="btn btn-secondary" aria-describedby="nav-help" data-action="exit-course"
|
|
141
|
+
data-testid="nav-exit" data-tooltip="Save progress and exit"><!-- icon -->Exit Course</button>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
<!-- Screen reader help text -->
|
|
145
|
+
<div id="nav-help" class="sr-only">Use Previous/Next to navigate, or use keyboard shortcuts</div>
|
|
146
|
+
</footer>
|
|
147
|
+
|
|
148
|
+
<!-- ========== GLOBAL MODAL ========== -->
|
|
149
|
+
<div id="global-modal" class="modal" role="dialog" aria-labelledby="global-modal-title"
|
|
150
|
+
aria-describedby="global-modal-body" aria-hidden="true" data-testid="modal-global">
|
|
151
|
+
<div class="modal-header">
|
|
152
|
+
<h2 id="global-modal-title" class="modal-title"></h2>
|
|
153
|
+
<button class="modal-close" aria-label="Close dialog" data-action="close-modal">×</button>
|
|
154
|
+
</div>
|
|
155
|
+
<div id="global-modal-body" class="modal-body">
|
|
156
|
+
<!-- Dynamic content injected here -->
|
|
157
|
+
</div>
|
|
158
|
+
<div id="global-modal-footer" class="modal-footer">
|
|
159
|
+
<!-- Dynamic buttons injected here -->
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<!-- Modal Backdrop -->
|
|
164
|
+
<div class="modal-backdrop" aria-hidden="true"></div>
|
|
165
|
+
|
|
166
|
+
<!-- ========== NOTIFICATIONS ========== -->
|
|
167
|
+
<div id="notification-container" class="notification-container" aria-live="polite" aria-relevant="additions"></div>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
<!-- ========== SCRIPTS ========== -->
|
|
171
|
+
<!-- Application entry point (loads SCORM API wrapper dynamically) -->
|
|
172
|
+
<script type="module" src="./js/main.js"></script>
|
|
173
|
+
</body>
|
|
174
|
+
|
|
175
|
+
</html>
|